DIY Rental Platform — Slice 6: Conversation Inbox, Tenant Overview, Tenant Offers
Slice 6 adds the two screens a rental user opens when they do not already know which record they want: a conversation inbox for both parties and a tenant Overview that answers "where am I, and what next?". It adds no tables, and it repairs two inherited defects — a settled-in tenant who cannot message anyone, and a landlord Offers page 404-ing in production.
- Predecessor Slice 5 — Move-in handover (2026-08-28)
- ADRs 0006
- Status Approved
Slices 1–5 built every step of a tenancy and gave each step a page. Slice 6 adds the connective tissue — the two screens a user opens when they do not already know which record they want: a conversation inbox for both parties, and a tenant Overview that answers "where am I, and what do I do next?". It adds no tables, and it repairs two defects it inherits along the way.
What slice 6 proves
Three deliverables:
-
A conversation inbox
For both tenant and landlord — every thread in one place, with unread counts, reachable without first finding the right viewing request.
-
A tenant Overview
One card per property the tenant is progressing on, each showing the current step and the next action.
-
A tenant Offers page
Which requires an endpoint the frontend already calls and the backend never registered.
Success criteria
| # | Criterion |
|---|---|
| 1 | A tenant with an active tenancy can message their landlord, from the inbox, even when that conversation holds no messages yet. |
| 2 | Either party opens one inbox and sees every conversation, newest activity first, with unread counts. |
| 3 | The nav badge reflects unread messages within 30 seconds while the tab is in the foreground and the threads fetch is succeeding. |
| 4 | A tenant landing on /tenant sees one card per property they are progressing on, each showing the current step and next action. |
| 5 | GET /rental/me/offers exists and both the tenant and landlord Offers pages work against the real route. |
| 6 | A thread's writability is decided by one function; the inbox flag and the send guard cannot disagree. |
Two inherited defects
Both are repaired here because slice 6 cannot deliver its own scope without touching exactly these two code paths.
Defect A — a settled-in tenant cannot message anyone
blocks the inboxSendMessagerejects any request in a terminal status (viewing_request.go:194), andcompletedis terminal.- A viewing is marked
completedbefore the offer, the agreement and the tenancy. - So by the time a relationship actually exists, every thread between the two parties is frozen.
- An inbox over frozen threads is a filing cabinet, not a messaging feature.
Defect B — the landlord Offers page is 404-ing in production
live bugfeatures/rental/api/offers.ts:165callsGET /rental/me/offers.offer_handler.go:29-42registers no list route.- The page's unit tests (
landlordOffers.test.tsx:46) mock the whole API module, so the suite is green while the live page fails. - Slice 6 needs this endpoint anyway, so building it fixes the landlord page as a side effect.
Scope
In scope
GET /rental/me/threads— the inbox projection.GET /rental/me/offers?status=— party-scoped offer list.entity.IsThreadWritable— one rule, two callers.ThreadRepository.RelationshipByRequestID— the single-request loaderSendMessageneeds, sharing its SQL fragments withListForUser.- Relaxation of the
SendMessageterminal guard per that rule. - One partial index supporting the unread count. The participant indexes the inbox predicate needs already exist.
- Routes:
/tenant(rewritten),/tenant/messages,/tenant/offers,/landlord/messages. MessageThread.tsxextracted fromViewingRequestDetailView.tsx.journeyProjection.ts— a pure client-side join producing journey cards.badge?: numberonRentalNavigationItem; unread dot on the mobile "More" button.- i18n keys for all new surfaces (en / ms / zh).
Out of scope, by decision
| Deferred | Goes to | Why |
|---|---|---|
| Saved-homes account sync | Slice 7 | User decision. Currently localStorage in the Astro app. |
| Payment receipt PDF | Slice 7 | User decision. |
| SSE / websocket push | Later, if ever | Polling is sufficient at this thread volume and adds no infrastructure. |
| Separate unread-count endpoint | Later, if ever | The thread list is a few KB. Splitting it now is premature. |
| Move-out flow, deposit return | Future slice | Slice 6 only keeps the thread open long enough to have the conversation. |
| Deposit / ledger data on the Overview | Future slice | The Overview states no money fact. It links to the tenancy detail page, which already holds the ledger. |
A dedicated /messages/[id] route | Never | The thread renders in the inbox's second pane and on the request detail page. A third location would be a third thing to keep in sync. |
Deliberately not handled
- Message editing or deletion. Not in slice 2, not added here.
- Attachments in messages. Handover and payment proof already have purpose-built upload paths with their own retention rules. A general file drop into chat would bypass both.
- Typing indicators, read receipts beyond
read_at, reactions. Chat-app affordances with no bearing on a rental transaction. - Search across threads. A user has tens of threads, not thousands. Scrolling works.
The writability rule
A thread is writable when either the request is in a live status
(pending, countered, confirmed), or the request
is completed and the chain's resolved stage is still live.
One resolved stage, not three independent flags
The rule resolves ONE ChainStage — the furthest point the chain actually reached — and that stage alone decides.
entity.AgreementStatus has exactly three values: awaiting_signatures,
completed, voided (domain/entity/agreement.go:12-18). Nothing
ever moves a signed agreement out of completed, and every tenancy descends from a
completed agreement. So under an OR, HasLiveAgreement is true
for the rest of time, the grace check after it is unreachable, and ThreadGraceDays is
dead code.
Rejected: three independent booleans (HasLiveOffer, HasLiveAgreement,
TenancyEndsOn) combined with OR.
| Resolved stage | Writable when |
|---|---|
tenancy | Inside the grace window. The tenancy's dates are authoritative; an earlier-stage offer or agreement row underneath it never overrides them. |
agreement | Agreement status is awaiting_signatures or completed, and the agreement row is not soft-deleted. |
offer | Offer status is pending, countered or accepted, and no agreement row exists on that offer. |
none | Never. |
The grace window is 30 days, on KL calendar dates
Deposit return is discussed after move-out. Freezing the thread the moment a tenancy ends would remove the channel at precisely the moment it is most needed. Thirty days is long enough for a deposit conversation and short enough that a long-ended tenancy does not linger forever.
A tenancy ending 2026-09-30 is writable through the whole of 2026-10-30 in
Asia/Kuala_Lumpur, and frozen from 2026-10-31 00:00 +08:00. This matches
tenancyProjections.ts, which already compares KL calendar dates with
toKLDateString and klCalendarDayDiff rather than raw instants.
The full domain contract — ChainStage, ThreadRelationship, IsThreadWritable
// ChainStage is the furthest point the request's chain actually reached.
// It is ONE resolved value, not a set of independent flags: a chain that
// reached a tenancy is judged as a tenancy, whatever rows sit beneath it.
type ChainStage string
const (
ChainNone ChainStage = "none"
ChainOffer ChainStage = "offer"
ChainAgreement ChainStage = "agreement"
ChainTenancy ChainStage = "tenancy"
)
// ThreadRelationship carries the downstream facts IsThreadWritable needs.
// The repository resolves these in the same query that builds the thread row,
// so the rule stays a pure function over already-loaded state.
type ThreadRelationship struct {
RequestStatus string
Stage ChainStage
TenancyEndsOn *time.Time // set only when Stage == ChainTenancy
}
// ThreadGraceDays is how long after a tenancy ends the parties can still talk.
const ThreadGraceDays = 30
// klLocation is Asia/Kuala_Lumpur. ends_on is a calendar DATE, so the cutoff
// is built from its stored Y/M/D fields at KL midnight.
var klLocation = mustLoadKL()
// IsThreadWritable reports whether either party may still post to this thread.
// It is the ONLY expression of this rule. SendMessage enforces it; the inbox
// projection reports it.
func IsThreadWritable(rel ThreadRelationship, now time.Time) bool {
switch rel.RequestStatus {
case RequestStatusDeclined, RequestStatusWithdrawn, RequestStatusCancelled:
// A party walked away deliberately. Nothing downstream reopens this.
return false
case RequestStatusPending, RequestStatusCountered, RequestStatusConfirmed:
return true
case RequestStatusCompleted:
switch rel.Stage {
case ChainTenancy:
// The tenancy decides alone. An accepted offer or completed
// agreement underneath it never extends the window.
if rel.TenancyEndsOn == nil {
return false
}
return withinGrace(*rel.TenancyEndsOn, now)
case ChainAgreement, ChainOffer:
// The repository only emits these stages for live rows, so
// reaching here means the chain is still open.
return true
default: // ChainNone
return false
}
default:
return false
}
}
// withinGrace is true while now is BEFORE KL midnight starting
// endsOn + ThreadGraceDays + 1. The exclusive day-31 bound keeps all of
// day 30 inside the window.
//
// endsOn's Y/M/D are used exactly as stored. Do NOT call endsOn.In(kl)
// first: a DATE arrives as midnight in the driver's nominal zone, and
// converting it can shift it to the previous or next calendar day.
func withinGrace(endsOn, now time.Time) bool {
y, mo, d := endsOn.Date()
// KL midnight on the day after the grace window's last day.
cutoff := time.Date(y, mo, d+ThreadGraceDays+1, 0, 0, 0, 0, klLocation)
return now.Before(cutoff)
}
SendMessage replaces its IsTerminalRequestStatus check with a call to
ThreadRepository.RelationshipByRequestID plus this function, returning the existing
ErrInvalidStateTransition when it is false. The HTTP mapping to 409 is unchanged.
IsTerminalRequestStatus stays where it is — other call sites use it for viewing-request
state transitions, which is a different question from thread writability.
One function, two callers
entity.IsThreadWritable is called by SendMessage as the enforcement guard,
and by the me/threads projection to populate each thread's writable flag. If
these diverge, the UI shows a composer that the server then rejects, or hides one the server would
have accepted. A single function makes that class of bug unrepresentable. The server remains the
authority; the flag is a UI affordance and never substitutes for the guard.
ViewingRequestUseCase has seven dependencies today, none of which can see an offer, an
agreement or a tenancy. A new outbound port method carries the facts in:
// ThreadRepository
RelationshipByRequestID(ctx context.Context, requestID string) (entity.ThreadRelationship, error)
ThreadRepository becomes the eighth dependency. Rejected alternative: injecting the
offer, agreement and tenancy repositories separately, which would put chain-resolution logic in the
use case where it would drift from the ListForUser version of the same logic.
-
Authorization first
SendMessageloads the request and checks the caller is a party to it before callingRelationshipByRequestID. A caller must not be able to probe another user's chain state through timing or error shape. -
Shared SQL
RelationshipByRequestIDandListForUseruse the same chain fragment and the same fact mapping. An integration test asserts both return identicalThreadRelationshipvalues for the same request, across each stage.
What the inbox lists, and what it never touches
A conversation is listed when it is live, has history, or is writable
| # | Condition | Why |
|---|---|---|
| 1 | The request is in a live status — pending, countered, confirmed | A user can start a conversation about a viewing they just booked. |
| 2 | The thread has at least one message | History worth keeping, whatever the request status. |
| 3 | The request is completed and IsThreadWritable returns true | A conversation the user is allowed to start must be reachable. |
Condition 3 exists because without it an empty writable conversation is unreachable. A
completed request with a live tenancy and zero messages is writable, so the server accepts
a message — but the user has nowhere to type it, short of navigating back to the old viewing request
page. That is success criterion 1's exact scenario.
The scenario is reachable. Counter appends a negotiation message, and offer Create and
Accept normally append one too, so a negotiated request almost always carries at least one message.
Those appends are best-effort writes: when one fails the surrounding action still
succeeds and the thread stays empty. A straight pending → confirmed → completed path with
no counter also produces an empty thread.
- A declined, withdrawn or cancelled request with no messages. Never writable, and no history to keep. Without this a landlord with many listings accumulates dead rows.
- A
completedrequest whose tenancy is past the grace window and which holds no messages.
- Any conversation with messages, including a declined one. History with something in it is never hidden.
Architecture
Data model
No new tables. One new index, added in migrator_rental.go as
CreateRentalSlice6Indexes, following the existing CreateRentalSliceNTables style
and called from RunMigrations.
CREATE INDEX IF NOT EXISTS idx_rental_messages_unread
ON rental_messages(request_id, sender_user_id)
WHERE read_at IS NULL AND deleted_at IS NULL;
Renaming them to create near-duplicates was rejected: the existing indexes lead on exactly the
participant columns the inbox predicate filters by
(tenant_user_id = $1 OR owner_user_id = $1), so they are usable for it. No performance
benefit is claimed for this choice and no planner behaviour is guaranteed. The unread partial index
is the only addition, and it exists because no current index covers read_at IS NULL.
The existing idx_rental_messages_request (request_id, created_at ASC) already serves
the last-message lookup and is not changed.
GET /rental/me/threads
counterparty_roleis relative to the caller: the tenant sees"landlord", the landlord sees"tenant". The frontend splits one payload by this field.r.confirmed_timeis selected internally to gate the name. It is not in the response. No client needs it, and the gate is applied server-side before the payload is built.last_message_*arenullon a thread with no messages yet.last_message_bodyis truncated server-side to 160 code points, not 160 bytes.
Response shape
{
"threads": [
{
"request_id": "0198…",
"listing_id": "0197…",
"listing_title": "The Goodwood Residence",
"listing_area": "Bangsar South",
"counterparty_user_id": "usr_…",
"counterparty_name": "Daniel Tan",
"counterparty_role": "landlord",
"request_status": "completed",
"writable": true,
"unread_count": 2,
"last_message_body": "I'll bring the keys at 3pm.",
"last_message_at": "2026-09-13T09:45:00Z",
"last_message_sender_user_id": "usr_…"
}
]
}
if r := []rune(body); len(r) > 160 {
body = string(r[:160])
}
Two privacy gates
| Condition | counterparty_name |
|---|---|
confirmed_time IS NULL | extractFirstName(name) — the same helper the detail view uses before reveal |
confirmed_time IS NOT NULL | The available full name |
Name missing from users | Role label — "Landlord" / "Tenant" |
No claim is made that first-name extraction is correct for every real name. Malaysian names
frequently have no surname in the Western sense, and extractFirstName returns the
leading token whatever the structure. It is a pre-reveal reduction, not a parse.
listing_title is composed server-side: building_name when non-empty,
otherwise "{bedrooms}-bedroom {property_type} in {area}", with each part dropped when
absent. This duplicates the Astro app's getListingTitle; the duplication is accepted,
because extracting one label string into a shared cross-language package would cost more than it
saves.
Query shape — one statement, three LEFT JOIN LATERAL
SELECT
r.id, r.listing_id, r.status,
r.confirmed_time, -- internal only: gates the name, never serialised
r.tenant_user_id, r.owner_user_id,
p.building_name, p.area, p.property_type, p.bedrooms,
m.body, m.created_at, m.sender_user_id,
u.unread AS unread_count,
COALESCE(rel.stage, 'none') AS chain_stage,
rel.tenancy_ends_on
FROM rental_viewing_requests r
JOIN rental_listings l ON l.id = r.listing_id
JOIN rental_properties p ON p.id = l.property_id
LEFT JOIN LATERAL (
SELECT body, created_at, sender_user_id
FROM rental_messages
WHERE request_id = r.id AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 1
) m ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*) AS unread
FROM rental_messages
WHERE request_id = r.id AND sender_user_id <> $1
AND read_at IS NULL AND deleted_at IS NULL
) u ON TRUE
LEFT JOIN LATERAL (
SELECT
CASE WHEN t.id IS NOT NULL THEN 'tenancy'
WHEN a.id IS NOT NULL THEN 'agreement'
ELSE 'offer' END AS stage,
t.ends_on AS tenancy_ends_on
FROM rental_offers o
LEFT JOIN rental_tenancy_agreements a ON a.offer_id = o.id
LEFT JOIN rental_tenancies t ON t.agreement_id = a.id
WHERE o.request_id = r.id
AND o.deleted_at IS NULL
AND (
(a.id IS NULL AND o.status IN ('pending','countered','accepted'))
OR
(a.id IS NOT NULL AND a.deleted_at IS NULL
AND a.status IN ('awaiting_signatures','completed'))
)
ORDER BY (t.id IS NOT NULL) DESC,
(a.id IS NOT NULL) DESC,
t.ends_on DESC NULLS LAST,
o.created_at DESC, o.id DESC
LIMIT 1
) rel ON TRUE
WHERE r.deleted_at IS NULL
AND (r.tenant_user_id = $1 OR r.owner_user_id = $1)
AND (
r.status IN ('pending','countered','confirmed') -- condition 1
OR m.created_at IS NOT NULL -- condition 2
OR r.status = 'completed' -- candidate for condition 3
)
ORDER BY COALESCE(m.created_at, r.created_at) DESC, r.id DESC;
| Case | Result |
|---|---|
| Agreement soft-deleted | Both branches false → chain excluded, no offer fallback |
Agreement voided | Status not in the list → chain excluded |
Offer declined/withdrawn, no agreement | Excluded |
| Tenancy under a valid agreement | stage = 'tenancy', ends_on carried |
| No chain survives | Lateral returns no row → stage = 'none' |
rental_tenancies is joined with no predicate. It has no deleted_at column
and no status column (migrator_rental.go:547-558), so there is nothing to
filter and the projection must never reference t.deleted_at. Tie-breaks end on
o.id and the outer ORDER BY ends on r.id, so both orderings are
deterministic.
The SQL selects candidates; the domain rule decides
The WHERE clause is a candidate filter, not the visibility rule. It must
not restate condition 3, because writability has exactly one expression and it lives in Go. So the
query lets every completed request through, with or without messages, and the application
filters afterwards:
for _, row := range rows {
rel := entity.ThreadRelationship{
RequestStatus: row.Status,
Stage: row.ChainStage,
TenancyEndsOn: row.TenancyEndsOn,
}
writable := entity.IsThreadWritable(rel, now) // already needed for the `writable` field
live := entity.IsLiveRequestStatus(row.Status)
if !live && row.LastMessageAt == nil && !writable {
continue // no live status, no history, not writable
}
out = append(out, toThread(row, writable))
}
The filter costs nothing extra: IsThreadWritable is already called for every row to
populate the writable field. Reusing that one verdict is what keeps the inbox flag and the
send guard incapable of disagreeing.
Declined, withdrawn and cancelled requests with no messages are excluded in SQL. That is not a duplicate of the writability rule — it is a set the rule provably rejects for every possible chain state, and those rows carry no history to preserve. The post-filter drops rows, so the returned count is smaller than the scanned count; there is no pagination on this endpoint, so nothing downstream depends on the two matching.
Counterparty display names are resolved in a second step via
UserRepository.ContactsByIDs, batched over the distinct ids in the result set — one extra
query for the whole page, never one per row.
GET /rental/me/offers
Mirrors the shape of the existing GET /me/agreements and GET /me/tenancies.
- Filter
- Optional
?status=, one of the fiveOfferStatusvalues. Unknown value → 400. - Scoping
- Rows where
tenant_user_id = sessionorlandlord_user_id = session. - Ordering
updated_at DESC- Envelope
{"offers": [...]}- Schema change
- None —
rental_offersalready carries every column needed.
The frontend's listMyOffers already tolerates both a bare array and this wrapped
envelope (offers.ts:165-167), so the existing landlord page starts working with no client
change. This endpoint has no dependency on the threads work and can ship on its own.
Frontend
| Route | Replaces / adds | Content |
|---|---|---|
/tenant | Rewrites routes/tenant/index.tsx | Journey cards |
/tenant/messages | New | Inbox |
/tenant/offers | New | Offers list, reusing OfferQueue in tenant role |
/landlord/messages | New | Inbox |
One endpoint, two workspaces
GET /rental/me/threads is scoped to the session user, not to a role. A
dual-role user — a landlord who also rents — gets every conversation from both sides in one payload.
The frontend splits it by counterparty_role: /tenant/messages and the tenant
badge show landlord threads; /landlord/messages and the landlord badge show
tenant threads.
A second role-scoped endpoint was rejected. A role parameter on the server would add an authorization surface where a client-side filter over already-authorized rows is sufficient. Two consequences: cache keys include the user id (two accounts on one browser must not share a thread cache), and dual-role isolation is tested.
Inbox layout
Desktop: two panes, thread list left, selected thread right. Selection is local component state holding a request id — no route parameter, so there is no second URL representing a conversation. Mobile: the list fills the screen; selecting a thread replaces it, with a back control. Returning to the list clears the selection, which stops that conversation's poll.
The thread rendering currently inline in ViewingRequestDetailView.tsx:250-320 moves to
MessageThread.tsx with props { messages, currentUserId }. Every
data-testid moves with it unchanged, so the existing detail-view tests keep passing
without edits — that is the check on the extraction.
Polling, not push
refetchInterval: 30_000 on the threads query, plus invalidation on send so a user's own
message appears immediately. SSE would need a long-lived connection to survive the SolidStart API
proxy, Fly.io idle timeouts, and reconnect/backoff — real infrastructure work, justified only by a
latency requirement this product does not have.
| Rule | Reason |
|---|---|
| No thread is auto-selected on load | Auto-selecting would mark a conversation read that the user never opened |
| Selection is by request id, held in local state | A thread is a request; nothing else identifies it |
| Only the explicitly selected, visible conversation polls, every 30 s | One open conversation, one read-marking fetch |
Polling pauses when document.hidden, and on mobile when the view returns to the list | A hidden conversation must not consume its own unread state |
| Focus refetch is disabled while the conversation is hidden | Same reason |
| Reopening the conversation refetches immediately | The user expects current messages on open |
The tenant Overview joins on the client
journeyProjection.ts is a pure function over five sources, not four:
requests, me/offers, me/agreements, me/tenancies, and
handover reports. The fifth is the correction — listHandoverReports takes a single tenancy
id (features/rental/api/handover.ts:309-314), so handover data arrives as N requests, one
per eligible tenancy, and the projection receives a map keyed by tenancy id.
An aggregate GET /rental/me/journey was rejected: it would duplicate, server-side, a
join the client already performs, and would need its own cache invalidation on every step transition
in five slices' worth of endpoints. This follows tenancyProjections.ts and
paymentSignals.ts, which already do exactly this kind of join with unit tests and no
network.
Card identity is the listing, after chaining
A tenant can have several requests on the same listing over time — one declined in March, one live in September. Keying cards by request id would render both, contradicting success criterion 4. The order of operations is fixed:
-
Chain
Build candidate chains by id:
request_id → offer_id → agreement_id → tenancy. -
Filter
Drop chains that are genuinely terminal — declined/withdrawn/cancelled request, voided agreement, tenancy past grace — using known facts only. A chain whose evidence is unavailable is not dropped; it is marked.
-
Group
Group the survivors by
listing_id. -
Select
Within each group take the most advanced chain. Tie-break by latest activity, then by request id ascending, so the result is deterministic.
The journeyProjection.ts contract
// Each member names the evidence that selects it, and nothing more.
// No member asserts money settled, tenant acceptance, or that the
// tenancy is active or ended.
export type JourneyStep =
| 'viewing' // a request, nothing downstream
| 'agreement' // a live offer, or an agreement with no tenancy
| 'moving_in' // tenancy exists, no move-in report
| 'handover_review' // move-in report exists, not finalized
| 'move_in_recorded'; // move-in report finalized
// A journey whose progress cannot be determined from known facts.
// This is NOT a step. It suppresses every progress claim and every action.
export const UNKNOWN_PROGRESS = null;
export type JourneyAction =
| 'view_request'
| 'review_offer'
| 'sign_agreement'
| 'review_handover' // → the handover review route, keyed by REPORT id
| 'view_payment_details' // → the tenancy detail route, keyed by TENANCY id
| 'open_messages';
export interface JourneyCard {
listingId: string; // identity: one card per listing
listingTitle: string;
requestId: string; // non-null: every selected chain starts at a request
tenancyId: string | null; // set from `moving_in` onward
// null means UNKNOWN, never "the earliest step we can still justify".
step: JourneyStep | null;
// Always null when step is null. An action is a claim about what comes next,
// and nothing can be claimed about an unknown position.
action: JourneyAction | null;
href: string | null; // the action's target; null when action is null
// Present whenever tenancyId is set, independent of `action`. Money is always
// one click away, even when the suggested next action is the handover.
paymentDetailsHref: string | null;
blocked: boolean; // this card's own detail source is unavailable
}
export interface JourneyResult {
cards: JourneyCard[];
degradedSources: string[]; // page-level: which sources are unavailable
}
// Every source arrives as an explicit state, not a bare array. A bare array
// cannot distinguish "no rows" from "we could not load it", and that
// distinction decides whether a card states a step or states nothing.
export type SourceSlot<T> =
| { state: 'loading' }
| { state: 'error' }
| { state: 'success'; data: T };
export type HandoverSlot = SourceSlot<HandoverReportSummary[]>;
export function projectJourneys(input: {
requests: SourceSlot<ViewingRequest[]>;
offers: SourceSlot<Offer[]>;
agreements: SourceSlot<TenancyAgreement[]>;
tenancies: SourceSlot<Tenancy[]>;
// Per-tenancy. A tenancy id absent from the map is 'loading',
// never an empty report list.
handoversByTenancyId: Record<string, HandoverSlot>;
}): JourneyResult;
Step resolution, most advanced wins
| Evidence | Step | Action | href |
|---|---|---|---|
| Tenancy exists, move-in report finalized | move_in_recorded | view_payment_details | /tenant/tenancies/{tenancyId} |
| Tenancy exists, move-in report exists, not finalized | handover_review | review_handover | /tenant/handover/{reportId} |
| Tenancy exists, no move-in report (successful empty list) | moving_in | view_payment_details | /tenant/tenancies/{tenancyId} |
| Agreement exists, no tenancy | agreement | sign_agreement | /tenant/agreements/{agreementId} |
| Live offer, no agreement | agreement | review_offer | /tenant/requests/{requestId}/offer |
| Request only | viewing | view_request | /tenant/requests/{requestId} |
Any source this chain needs is loading or error | null | null | null |
The three tenancy rows are ordered by evidence: no report → report filed → report finalized.
moving_in applies only to a successful, empty report list. A
loading or error slot is unknown progress, never moving_in.
HandoverReportSummary includes report_type, so slice 6 reads only
move_in reports.
The report id comes from the HandoverReportSummary the projection already holds, so no
new data source is needed. The link target is the tenancy detail page, not
/tenant/tenancies/{id}/pay — the detail page holds the ledger, and it is what decides
whether paying is the next thing at all.
What these labels do not say
Each label names an observation about a move-in report. None of them asserts anything further:
- Not tenancy status. Scheduled, active and ended come from the KL calendar-date comparison in
tenancyProjections.ts, never from the handover. The two are shown as separate facts, and the move-in progress label is never rendered as the card's headline state on its own — a tenancy that never had a report filed would otherwise read "Moving in" months after the tenant moved in. - Not financial settlement. A handover report records condition, not payment. The Overview states no money fact;
paymentDetailsHrefsends the tenant to the ledger to see it. - Not tenant acceptance. Finalization has two reasons —
tenant_acceptedanddeadline_elapsed(migrator_rental.go:824-842).move_in_recordedis deliberately neutral between them. - Not termination. Nothing here says a tenancy ended.
Cards sort by step descending, then by most recent activity, then by request id. A card with
step: null sorts last.
Action labels and navigation
action is a narrow union, and the view maps it with an exhaustive switch to
static Paraglide function calls, exactly like getTermLabel in
OfferTermsComparison.tsx:24-43. The reason is type safety, an explicit list of supported
actions, and consistency with the existing repository pattern. No tree-shaking behaviour is
asserted.
const getActionLabel = (a: JourneyAction): string => {
switch (a) {
case 'view_request': return m.rental_journey_action_view_request();
case 'review_offer': return m.rental_journey_action_review_offer();
case 'sign_agreement': return m.rental_journey_action_sign_agreement();
case 'review_handover': return m.rental_journey_action_review_handover();
case 'view_payment_details': return m.rental_journey_action_view_payment_details();
case 'open_messages': return m.rental_journey_action_open_messages();
}
};
paymentDetailsHref needs no action member of its own. It is a secondary link on every
tenancy card, labelled once, shown whenever tenancyId is set.
RentalNavigationItem gains badge?: number. When badge is a
positive number the link renders a count; zero and undefined render nothing. Post-slice-6
both roles overflow the mobile nav, so the tenant sees Overview / Viewings / Messages / Offers
directly with Agreements and Tenancies in "More", and the landlord sees Dashboard / Properties /
Viewings / Messages with Offers, Tenancies and Payments in "More".
Error handling
| Case | Behaviour |
|---|---|
| POST to a non-writable thread | ErrInvalidStateTransition → 409. The flag hides the composer; the server decides. |
Counterparty absent from users | Fall back to the role label ("Landlord" / "Tenant"). A deleted account must not 500 the inbox. |
me/threads fails on first load | Inbox shows a retry card. The badge renders nothing — never a zero, never a stale count. |
me/threads background refetch fails | The thread list keeps the last good data. The badge still renders nothing while the current fetch is errored, because a count that may be wrong is worse than no count. No toast. |
me/offers fails | Error card with retry, matching the landlord page. |
| A journey list source is unavailable | Add it to degradedSources. Render the cards that still resolve, showing only known display facts. Affected cards get step: null and action: null. Never state a step the missing source could contradict. |
| One tenancy's handover fetch fails | Mark that card blocked: true, step: null, action: null, with an inline warning. It does not fall back to an earlier step — an earlier step is a confident claim, and the evidence for it is exactly what is missing. Other cards are unaffected. |
| A handover fetch is still loading | Same as failed: unknown progress. Never "no report", never an earlier step. |
?status= not a valid OfferStatus | 400 with the existing validation error shape. |
Empty states are required on all three new surfaces. A new seeker has no threads, no offers and no journeys, and that is the first thing they will see.
Testing
Full test matrix
| Layer | Coverage |
|---|---|
| Go domain | IsThreadWritable table test: every request status × every ChainStage. Grace boundary at day 29 (writable), day 30 (writable), day 31 (frozen). |
| Go domain, timezone | KL is UTC+8, so KL is always ahead. The case that catches a UTC comparison is an instant still day 30 in UTC while KL has already entered day 31 — it must be frozen. Plus both edges: 23:59:59 +08:00 on day 30 → writable; 00:00:00 +08:00 on day 31 → frozen. |
| Go domain, DATE mapping | An ends_on read back as midnight UTC produces the same verdict as the same date read back as midnight KL. |
| Go domain | A completed request at ChainTenancy past grace stays frozen even with an accepted offer, a completed agreement, or a newer pending offer row present. |
| Go repository | The chain lateral: soft-deleted agreement → stage = 'none', not 'offer'. voided agreement → 'none'. Deleted offer ignored. Multiple candidate chains → deterministic winner. |
| Go repository | RelationshipByRequestID and ListForUser return identical ThreadRelationship values for the same request, at each stage. |
| Go handler, real Postgres | GET /me/threads: party scoping, unread counts, ordering by last activity, null last-message fields. |
| Go handler, listing rule | A completed request with no messages and a live tenancy is listed, with writable: true. The same request past grace is not listed. A declined request with no messages is not listed. A declined request with messages is listed, with writable: false. |
| Go handler, listing rule | The SQL candidate set includes empty completed rows, and the Go post-filter drops the ones the domain rule finds unwritable. |
| Go handler, i18n-safe | A 200-code-point Chinese message returns exactly 160 code points of valid UTF-8, not 160 bytes. |
| Go handler, privacy | Payload contains no address_line, including on a thread whose viewing was declined before contact reveal. |
| Go handler, privacy | counterparty_name is the first-name form while confirmed_time is null, and the full name after confirmation. |
| Go handler, label | listing_title uses building_name when set, and the composed fallback when it is empty. |
| Go handler, real Postgres | GET /me/offers: party scoping, status filter, invalid status → 400, envelope shape. Hits the registered route, not a mock. |
| Go regression | SendMessage on completed + live tenancy → 200. On declined → 409. On completed + tenancy past grace → 409. |
| Go regression | SendMessage authorization runs before the relationship load: a non-party gets the same rejection whatever the chain state. |
| Go regression | The inbox query performs no writes: read_at is unchanged after a me/threads call on a thread with unread messages. |
| Vitest, pure | projectJourneys: each step transition, several parallel journeys, terminal-chain exclusion. |
| Vitest, pure | One card per listing: a historical declined request plus a current live request on the same listing yields exactly one card, carrying the live chain's requestId. |
| Vitest, pure | A loading or error handover slot never resolves to "no report". The card gets step: null and action: null — not advanced, not demoted. |
| Vitest, pure | An error list source populates degradedSources, and every card that needed it has step: null. Cards that did not need it are unaffected. |
| Vitest, pure | The three post-agreement labels, each selected by its own evidence: tenancy + successful empty report list → moving_in; report present, not finalized → handover_review; finalized → move_in_recorded. |
| Vitest, pure | move_in_recorded is returned for both finalization_reason values. |
| Vitest, pure | paymentDetailsHref is present on every card with a non-null tenancyId, including handover_review cards. Null on viewing and agreement cards. |
| Vitest, pure | review_handover's href carries the handover report id, not the tenancy id. A fixture with different ids for the two catches a swap. |
| Vitest, pure | The move-in progress label and the date-derived tenancy status are independent; neither value overwrites the other. |
| Vitest, component | Inbox renders and sorts threads; no thread selected on load; badge sums only the workspace's counterparty_role; badge renders nothing while the threads fetch is errored; composer disabled when writable: false; "More" dot appears when an overflow item has unread. |
| Vitest, component | Opening a thread refetches threads rather than writing zero: when the refetched payload still reports unread, the badge shows it. |
| Vitest, component | Dual-role isolation: one user who is landlord on one listing and tenant on another sees each conversation in exactly one workspace. |
| Vitest, regression | ViewingRequestDetailView tests pass unchanged after the MessageThread extraction. |
E2E (ego lite) | Tenant sends a message → landlord inbox shows unread → landlord opens the thread → count clears. |
Task ordering
Work forms three groups. Within each group the order is fixed. The groups are not all independent.
| Group | Depends on |
|---|---|
| A — the send fix | Nothing |
| B — the offers endpoint | Nothing |
| C — the inbox and Overview | Group A step 2 (the chain SQL), and Group B step 4 for the Overview's offer data |
Only Group B ships entirely on its own. Group A ships on its own too, but it is a prerequisite for Group C rather than a peer of it.
Group A — the send fix (Defect A)
ChainStage,ThreadRelationship,IsThreadWritable+ domain tests.ThreadRepository.RelationshipByRequestID(the chain SQL) + repository tests, and wireThreadRepositoryintoViewingRequestUseCase.SendMessagedelegates to the rule; regression tests. Defect A repaired here.
Group B — the offers endpoint (Defect B)
GET /me/offers+ handler test hitting the registered route. Defect B repaired here./tenant/offerspage.
Group C — the inbox and Overview
idx_rental_messages_unreadinmigrator_rental.go.ThreadRepository.ListForUser+GET /me/threads+ handler tests. Needs Group A step 2 — it reuses the same chain SQL and fact mapping.MessageThread.tsxextraction; existing detail-view tests must stay green. Needs nothing; can start any time.badgeonRentalNavigationItem; "More" unread dot./tenant/messagesand/landlord/messages, split bycounterparty_role.journeyProjection.ts+ pure tests. Needs Group B step 4 — the Overview readsme/offers./tenantOverview rewrite.- Cross-cutting: i18n keys en / ms / zh, then E2E last.
Product questions, answered
Two product choices were open during review. Both are now decided. They are recorded here with what was rejected, so a later reader does not re-open them by accident.
Q7 — the step between agreement and handover
Neutral move-in labels + payment link
- Labels
moving_in,handover_review,move_in_recorded- Data source
- Existing tenancy + move-in handover facts
- New fetches
- None
- Money claim
- None — links to the ledger
Selected by move-in report evidence alone. Every tenancy card carries
paymentDetailsHref to /tenant/tenancies/{tenancyId}, where the ledger already
lives.
A deposits step
- Data source
- Per-tenancy ledger fetch (a sixth source)
- New fetches
- N, one per tenancy
- Money claim
- "These payments are outstanding"
- Failure risk
- A wrong ledger reading states a false money fact
The original draft inferred deposits from the absence of a handover report.
That inference does not hold: a missing report is evidence about a report, and a finalized
handover says nothing about settlement either.
F1 — a writable conversation with no messages
Yes — it is listed
- Rule
- Live, OR ≥1 message, OR
completedAND writable - Where filtered
- Go, after the domain rule
- Criterion 1
- Holds through the inbox
The SQL widens the candidate set and decides nothing, which keeps one copy of the writability rule. Empty expired and empty declined conversations stay hidden; a declined request that has messages stays visible.
Keep the message-only filter
- Rule
- Live, OR ≥1 message
- Criterion 1
- Holds only via the detail page
- Cost
- None
Rejected because the request detail page is not where a tenant looks for a conversation. Also
rejected: rel.stage IS NOT NULL as the SQL predicate — it lists expired tenancies past
the grace window, and it is a second, weaker copy of the writability rule.