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.
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.
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.
| Slice | Contents | Depends on |
|---|---|---|
| 1 (this doc) | Owner onboarding, property listings, admin approval, public browse | — |
| 2 | Viewing booking, in-platform messaging, reminders | 1 |
| 3 | Offer to tenancy agreement, e-signature, document storage | 2 |
| 4 | Rent payment tracking, reminder ladder, late notice PDFs | 3 |
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-uppage (Better AuthsignUp.email, no invite required) plus a narrow rental endpoint that promotes the new account to a globallandlordrole — 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
| Piece | Location | Reuses |
|---|---|---|
| Backend module | backend/go/internal/modules/rental/ (domain / application / adapter) | user (Better Auth), storage, audit — not notifications or organizations |
| Deployment | New 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 site | New Astro app frontend/astro/apps/rental on Cloudflare Workers, SSR | @astro/core, shared-ui |
| Owner dashboard | frontend/solidstart/apps/panel → /owner/* routes with an owner.tsx layout | Panel components, Paraglide i18n, better-auth, AdminRoute's client-side gating pattern |
| Rental admin | Same 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.
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.
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.
| Table | Key columns |
|---|---|
rental_properties | owner_user_id, property_type, address_line, area/city/state/postcode, bedrooms, bathrooms, furnishing, has_parking, declared_owner_at, declared_ip |
rental_listings | property_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_photos | property_id, storage_key, sort_order, is_cover |
rental_ownership_proofs | property_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
usersrecord is enough for slice 1 - Admin actions
- No table — review fields live on the listing; the shared
auditinfrastructure records who did what
Listing state machine
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
-
Sign up
New landlord signs up at a public
/owner/sign-uppage (Better AuthsignUp.email, no invite needed); a narrow rental endpoint promotes the account to the globallandlordrole. -
Fill the property form
At
/owner/properties/new. -
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.
-
Upload ownership proof
Private prefix, via a rental-owned upload endpoint; the storage layer never returns a public URL for it.
-
Declare ownership
Ticks the ownership declaration;
declared_owner_atanddeclared_ipare (re-)stamped on this submission. -
Submit
Status becomes
pending_review. -
Admin discovers it
By visiting the filtered review queue — no notification is sent in slice 1 (the shared
notificationsmodule requires an organization, which a landlord doesn't have). -
Approve or reject
Admin approves — status
live, slug generated, owner emailed. Or rejects —review_reasonwritten, 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
| Content | Prefix | Access |
|---|---|---|
| Listing photos | public | CDN-cacheable public URL |
| Ownership proofs | private | Streamed 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 group | Check |
|---|---|
| Owner | AuthMiddleware + global landlord role from claims.Roles + row-level check that owner_user_id matches the session user, performed in the use case |
| Admin | AuthMiddleware + the existing global admin/superadmin platform role — reused as-is, no new role value |
| Public | OptionalAuthMiddleware or no auth |
Error handling
| Case | Behaviour |
|---|---|
| Upload wrong type or too large | Rejected at the API with a clear message. Limit 10 photos, 5MB each. Proof accepts PDF, JPG, PNG |
| Submit without proof or declaration | Blocked in the use case, not only in the form — the API is the trust boundary |
| Owner edits a live listing | Returns to pending_review if address, rent, or proof changed. Stays live for description and photo edits |
| Same address submitted twice | Flagged 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 listing | Idempotent — no duplicate email, no state churn |
| Rental API unavailable | Astro 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 → liverestore and starting a new listing afterwithdrawn
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.gopattern
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.
- Backend skeleton
Module skeleton, config profile, migrations → tables exist, health check passes.
- Property + listing CRUD
Owner CRUD with authorization tests → API works via curl.
- Upload
Photo and proof upload, both storage prefixes → files land in the right buckets.
- Lifecycle
Submit, state machine, admin approve, reject, suspend, and restore → full lifecycle via API.
- Public read API
Public read API and golden test → live listings queryable, nothing leaks.
- Owner UI
Panel
/owner/*routes and layout → owner lists a property in a browser. - Admin UI
Panel
/admin/rental/*review queue → admin approves in a browser. - Public site
Astro search, filters, detail page, SEO tags → tenants can find listings.
- E2E + deploy
Playwright happy path, deploy both frontends.
Legal note
The platform is a tool provider, not a real estate agent and not a legal advisor. Terms must state that listings are for property owners listing their own properties. This matters more from slice 3 onward, when the system generates tenancy agreements, but the positioning belongs in the terms from launch.