中文版

DIY Rental Platform — Slice 1: Listings and Owner Onboarding

Slice 1 of a Malaysian rental marketplace: landlord signup, property listings, manual admin approval, and public browse — built inside the existing monorepo rather than greenfield. A 2026-08-24 design-review grilling session settled the architecture on the platform's shared Postgres database and Better Auth deployment, resolved a role-token naming collision with the existing org-owner role, and tightened the data model, storage, and state-machine details before implementation starts.

View source markdown ↗ generated by claude-sonnet-5 · diagrams mermaid

Slice 1 of a Malaysian rental marketplace: landlord signup, property listings, manual admin approval, and public browse — built inside the existing monorepo rather than greenfield. A 2026-08-24 design-review grilling session (15 questions) settled the architecture on the platform's shared Postgres database and Better Auth deployment, resolved a role-token naming collision with the existing org-owner role, and tightened the data model, storage, and state-machine details before implementation starts.

  • StatusApproved, grilled 2026-08-24
  • BackendNew rental Go module, shared DB (ADR-0055)
  • Public siteNew Astro app on Cloudflare Workers
  • DashboardsSolidStart panel, /owner/* + /admin/rental/*
  • AuthShared Better Auth, new landlord role token
  • Slice order1 of 4 (listings → booking → tenancy → payments)

Why this document exists

plan.md describes a whole PropTech company — agent-free marketplace, legal document automation, e-signing, rental enforcement, utility bill monitoring, and a property-management operation with field staff. Even its "MVP" holds six subsystems. That's too large for one spec, so this document covers slice 1 only; later slices get their own spec, plan, and build cycle.

SliceContentsDepends on
1 (this doc)Owner onboarding, property listings, admin approval, public browse
2Viewing booking, in-platform messaging, reminders1
3Offer to tenancy agreement, e-signature, document storage2
4Rent payment tracking, reminder ladder, late notice PDFs3

Everything else in plan.md — property management operations, TNB and water APIs, maintenance tickets, law firm escalation, loyalty programmes, agent behaviour detection — is Phase 2 or later, not designed here.

What slice 1 proves

A Malaysian property owner will create an account, list a real property, pass ownership review, and see it live — and tenants can find it. Nothing else. If owners will not do this, no further slice is worth building.
  • An owner completes listing submission without help
  • An admin approves or rejects from the panel with a reason the owner can read
  • A tenant finds a listing through search and filters, with no account
  • No public response ever contains owner contact details or ownership documents

Scope

In scope

  • Owner signup and login: a public /owner/sign-up page (Better Auth signUp.email, no invite required) plus a narrow rental endpoint that promotes the new account to a global landlord role — no self-serve signup path exists in the panel today, so this is new work, not reuse
  • Create and edit a property: type, address, area, rent, deposit terms, furnishing, house rules, available date, photos
  • Ownership declaration checkbox plus proof document upload (utility bill, quit rent, or SPA first page)
  • Admin review queue: approve, reject with reason, suspend a live listing, restore a suspended listing
  • Public marketplace: browse, filter by area / price / type / furnishing, listing detail page, "Direct from Owner" badge
  • Owner contact details never shown publicly. The detail page shows a disabled "Arrange Viewing" button labelled coming soon

Out of scope, by decision

Viewing booking, messaging, offers, tenancy agreements, e-sign, payments, the refundable listing deposit, tenant accounts, ratings, agent behaviour detection, property management operations, utility APIs. Tenants browse without an account in slice 1 — tenant login arrives with slice 2, the first slice that needs it.

Architecture

PieceLocationReuses
Backend modulebackend/go/internal/modules/rental/ (domain / application / adapter)user (Better Auth), storage, audit — not notifications or organizations
DeploymentNew rental entry in appProfiles; shared Postgres database and shared Better Auth (ADR-0055)Existing config and profile mechanism, existing DB connection — no new database
Public siteNew Astro app frontend/astro/apps/rental on Cloudflare Workers, SSR@astro/core, shared-ui
Owner dashboardfrontend/solidstart/apps/panel/owner/* routes with an owner.tsx layoutPanel components, Paraglide i18n, better-auth, AdminRoute's client-side gating pattern
Rental adminSame panel codebase, same deployment, /admin/rental/*Existing admin.tsx layout, review-queue patterns, single existing GO_API_URL proxy (no second deployment)

Shared database, shared Better Auth

Rental runs in the same Postgres database and the same Better Auth deployment as every other module, isolated only at the table level (rental_ prefix, ADR-0021).

The Go backend has no per-profile database mechanism today, and ADR-0006 ties Go's Better Auth reads to one shared Postgres instance — real physical isolation would require the same standalone pattern the quiz app uses. Rental's admin side deliberately reuses the same platform admins, which only works if identity is shared. Accepted as a conservative, repository-consistent default (ADR-0055) — not a proven pattern, since no existing module has served an orgless (B2C) user before.

Rejected: own database + own Better Auth deployment (quiz pattern, ADR-0054) — duplicates identity infrastructure for a feature whose admin side wants shared admins. Rejected: own database with shared Better Auth — not physically possible under ADR-0006.

Selected

Shared Postgres + shared Better Auth

Zero new infrastructure. Landlords are ordinary users rows with a global landlord role and no organization. Admins are the existing platform admins.

Rejected

Own database + own Better Auth (quiz pattern)

Real B2B/B2C isolation, but duplicates identity infrastructure and breaks admin reuse — quiz's admins and rental's admins would be different people entirely.

Public site in Astro, dashboards in SolidStart

Listing pages need search-engine indexing (Astro on Workers); owner and admin screens need tables, forms, and uploads (the SolidStart panel already has these built and tested).

Owner gets its own layout, not its own app

A lighter, consumer-facing routes/owner.tsx wrapping /owner/* is one new file, following the panel's existing per-folder layout pattern (routes/admin.tsx).

It copies the existing AdminRoute pattern — a client-side role check, with the real authorization boundary enforced server-side by the API's row-level owner_user_id check (§8) — since that's the only per-role layout precedent the panel has today.

One targeted cleanup

Rental models go in a new migrator_rental.go, following the existing migrator_morningroutine.go precedent, rather than growing the ~140KB migrator.go further.

Languages
English only at launch, wired through the same Paraglide setup as other apps, so Malay and Chinese can be added later without rework. Owner-written descriptions stay free text in whatever language the owner types.

Data model

Four tables, created by GORM AutoMigrate in a new migrator_rental.go.

TableKey columns
rental_propertiesowner_user_id, property_type, address_line, area/city/state/postcode, bedrooms, bathrooms, furnishing, has_parking, declared_owner_at, declared_ip
rental_listingsproperty_id, status, monthly_rent, deposit_months, utility_deposit, tenancy_months, available_from, description, house_rules, slug, published_at, expires_at, review_reason, reviewed_by, reviewed_at
rental_property_photosproperty_id, storage_key, sort_order, is_cover
rental_ownership_proofsproperty_id, doc_type, storage_key, status, reviewed_by, reviewed_at

Why property and listing are separate

Slice 1 creates them together, so merging would be shorter today. They stay separate because a property is re-listed every time a tenancy ends, and every later feature — tenancy, payments, maintenance, management — hangs off the property, not the advertisement.

Merging now would force a painful migration later.

Owner profile
No table — the existing users record is enough for slice 1
Admin actions
No table — review fields live on the listing; the shared audit infrastructure records who did what

Listing state machine

Listing lifecycle. Suspension is a reversible compliance hold; withdrawal is terminal for that listing row but not for the property
Listing lifecycle. Suspension is a reversible compliance hold; withdrawal is terminal for that listing row but not for the property.

withdrawn and suspended are not both dead ends: suspension is reversible straight back to live by an admin. Once a listing reaches withdrawn, the owner may create a brand-new listing (starting again at draft) for the same property (see §5's cardinality rule). This is the only real logic in slice 1 — covered by table-driven tests over every valid and invalid transition, including the restore and the post-withdrawal new listing.

Flows

A — Owner lists a property

  1. Sign up

    New landlord signs up at a public /owner/sign-up page (Better Auth signUp.email, no invite needed); a narrow rental endpoint promotes the account to the global landlord role.

  2. Fill the property form

    At /owner/properties/new.

  3. Upload photos

    Public storage prefix, via a rental-owned upload endpoint calling the shared storage interface directly — not the org-scoped shared attachments flow, since a landlord has no organization.

  4. Upload ownership proof

    Private prefix, via a rental-owned upload endpoint; the storage layer never returns a public URL for it.

  5. Declare ownership

    Ticks the ownership declaration; declared_owner_at and declared_ip are (re-)stamped on this submission.

  6. Submit

    Status becomes pending_review.

  7. Admin discovers it

    By visiting the filtered review queue — no notification is sent in slice 1 (the shared notifications module requires an organization, which a landlord doesn't have).

  8. Approve or reject

    Admin approves — status live, slug generated, owner emailed. Or rejects — review_reason written, owner sees it, fixes, resubmits (re-stamping the declaration).

B — Public browse, no login

GET /api/public/rental/listings?area=&min_rent=&max_rent=&type=&furnishing=&page=
GET /api/public/rental/listings/{slug}

Returns live listings only. The response carries no owner name, phone, email, exact address_line, or proof document — ever (only area/city/postcode, so a tenant can't show up unannounced before a viewing is arranged). Astro renders these server-side at /property/{slug} with cache headers so Cloudflare serves repeat traffic.

C — Admin review

/admin/rental/listings?status=pending_review in the panel, using the existing review-queue table patterns. Approve, reject with reason, suspend a live listing, or restore a suspended listing back to live. Ownership proofs are viewed by streaming through an authenticated admin-only endpoint — never a public or signed URL. If another rental_properties row shares the same normalized address, the review screen shows a plain warning banner (a read-time query, no new schema or notification).

Two boundaries that matter

Storage separation

ContentPrefixAccess
Listing photospublicCDN-cacheable public URL
Ownership proofsprivateStreamed through an authenticated admin-only endpoint; the shared storage interface only returns public URLs (PutObject), so proofs are never surfaced as a URL at all

Different prefixes, different access paths, never mixed.

Authorization

internal/api/http/middleware/better_auth_rbac.go's RequireOrgRole is organization-scoped and requires an organization ID in context. A B2C landlord has no organization, so rental must not use it.

Endpoint groupCheck
OwnerAuthMiddleware + global landlord role from claims.Roles + row-level check that owner_user_id matches the session user, performed in the use case
AdminAuthMiddleware + the existing global admin/superadmin platform role — reused as-is, no new role value
PublicOptionalAuthMiddleware or no auth

Error handling

CaseBehaviour
Upload wrong type or too largeRejected at the API with a clear message. Limit 10 photos, 5MB each. Proof accepts PDF, JPG, PNG
Submit without proof or declarationBlocked in the use case, not only in the form — the API is the trust boundary
Owner edits a live listingReturns to pending_review if address, rent, or proof changed. Stays live for description and photo edits
Same address submitted twiceFlagged for admin, not blocked — the admin review screen shows a warning banner listing other properties at the same normalized address (read-time query, no schema change, no notification). Different rooms in one unit are legitimate
Admin approves an already-approved listingIdempotent — no duplicate email, no state churn
Rental API unavailableAstro serves the cached CDN copy; the search page shows a plain "temporarily unavailable" state

Testing

Domain

state machine
  • Table-driven tests over every valid and invalid transition
  • Includes suspended → live restore and starting a new listing after withdrawn

Authorization

highest-risk
  • One test per owner endpoint proving user B cannot read or edit user A's property
  • First test in the codebase to exercise an orgless (landlord) user

Public API golden test

leak alarm
  • Asserts the exact JSON shape of a public listing response
  • Fails if anyone adds owner phone, email, exact address_line, or a proof key

Migration test

schema
  • Follows the existing *_migration_test.go pattern

Playwright happy path

e2e
  • Owner submits, admin approves, listing appears on the public site
  • One end-to-end test, not a suite

Delivery order

Each step ends with something observable. Backend through step 5 first means frontend work is never blocked on API shape.

  1. Backend skeleton

    Module skeleton, config profile, migrations → tables exist, health check passes.

  2. Property + listing CRUD

    Owner CRUD with authorization tests → API works via curl.

  3. Upload

    Photo and proof upload, both storage prefixes → files land in the right buckets.

  4. Lifecycle

    Submit, state machine, admin approve, reject, suspend, and restore → full lifecycle via API.

  5. Public read API

    Public read API and golden test → live listings queryable, nothing leaks.

  6. Owner UI

    Panel /owner/* routes and layout → owner lists a property in a browser.

  7. Admin UI

    Panel /admin/rental/* review queue → admin approves in a browser.

  8. Public site

    Astro search, filters, detail page, SEO tags → tenants can find listings.

  9. E2E + deploy

    Playwright happy path, deploy both frontends.