Work / Seedha Ghar / Wiki / Flows
load-bearing-flows
Flowcanonicalverified 2026-07-22
FLOW.LOAD-BEARING-FLOWSFlows — Load-bearing sequence diagrams
Five flows the architecture must survive. If any of these is wrong, the product is broken. Everything else in the app is CRUD.
Prices, states, and SLAs come from ../product/catalog.md.
1. Publish listing (seller pays ₹5, enters review)
Screens: S10 → S11a → (S11b conditional) → S11c → S12 → S13
seller → apps/mobile (or apps/web)
├─ 1. Draft: POST /listings → 201 { id, state=draft }
├─ 2. PATCH /listings/:id (basics, then location, then photos)
├─ 3. Photo upload: POST /listings/:id/photos
│ → api: signed R2 upload URL → mobile uploads bytes direct to R2
│ → api: INSERT photo row (grade=null pending worker)
│ → enqueue reverse-image-search + resize workers
├─ 4. Submit: POST /listings/:id/submit
│ begin transaction
│ SELECT wallet FOR UPDATE
│ IF balance < 500: rollback → 402 (recharge required)
│ INSERT wallet_ledger(delta=-500, reason='publish')
│ UPDATE wallet.balance -= 500
│ INSERT listing_version(snapshot, review_state='pending')
│ UPDATE listing.state = 'in_review'
│ commit
│ → enqueue notify(reviewer_queue)
│ → 200
└─ 5. State poll or WebSocket: GET /listings/:id → state='in_review'
Error paths:
- Photo grade='stolen_or_stock' worker sets listing.review_state='needs_changes'
with reason='photo:stolen'; state transitions to 'needs_changes' visible to seller (S14).
- Wallet debit fails after photo upload: photos survive as orphans, cleaned by
weekly janitor cron. Wallet is never charged for a rejected upload.
Invariants
- Exactly one wallet_ledger row per publish attempt (success or refund).
- Reviewer never sees a listing without at least one internal photo (blocked in step 3 check).
2. Buyer unlock (₹5 → owner contact revealed)
Screens: S34 → (S35 or S36) → S34b
buyer taps "Unlock owner contact · ₹5" on S34
→ apps/mobile → POST /unlocks { listing_id }
→ apps/api:
begin transaction
SELECT wallet FOR UPDATE where user_id=buyer
IF balance < 500 (paise):
rollback
return 402 { code: 'RECHARGE_REQUIRED', wallet_balance }
→ client shows S36 recharge gate, on success retries the POST /unlocks
IF listing.state != 'live':
rollback → 409 { code: 'LISTING_UNAVAILABLE' }
IF exists(unlocks where buyer_id, listing_id):
rollback → 200 { already_unlocked: true, contact } ← idempotent
INSERT wallet_ledger(delta=-500, reason='unlock', ref=listing_id, balance_after)
UPDATE wallet.balance -= 500
INSERT unlocks(buyer_id, listing_id, price_paid=500)
INSERT audit_events(actor=buyer, action='unlock', target=listing_id)
commit
→ enqueue notify(seller: "buyer X unlocked", buyer_interest_delta+1)
→ return { contact: { name, phone, whatsapp } }
→ mobile transitions to S34b, renders unlocked view
Refund path (buyer files scam complaint later):
→ admin resolves complaint with action='refund'
→ apps/api:
begin transaction
INSERT wallet_ledger(delta=+500, reason='refund_unlock', ref=unlock_id)
UPDATE wallet.balance += 500
UPDATE unlocks.refunded_at, refund_reason
INSERT audit_events(actor=admin, action='refund', target=unlock_id)
commit
→ notify(buyer)
Idempotency: repeated POST /unlocks for the same (buyer, listing) never double-charges. Enforced by unique index on (buyer_id, listing_id) where refunded_at IS NULL.
3. Thirty-day auto-takedown (cron)
Screens: S15 (live) → S16 (expired) → seller taps Renew · ₹5 → S13
Cloudflare Cron Trigger: every hour at :00
→ apps/api /cron/expire-listings (auth: cron secret)
→ SELECT id FROM listings
WHERE state='live' AND expires_at < now()
LIMIT 500 ← batch to stay under Worker CPU cap
→ for each: UPDATE listing.state='expired'
INSERT audit_events(actor='system', action='auto_expire', target=listing)
enqueue notify(seller: "listing expired, renew for ₹5")
→ if 500 returned, next tick picks up the rest (idempotent)
Renewal (user-triggered from S16):
→ POST /listings/:id/renew
→ same money+version+state transition as publish (§1),
but transitions state 'expired' → 'in_review',
creates new listing_version (reviewer sees the delta vs last approved version)
Invariants
listing.expires_at = last_published_at + 30 daysrecomputed on every publish and renewal.- Renewals go through review — auto-approve is forbidden.
4. Reviewer verification queue
Screens: A01 → A02 → A03 → seller S13 (updates)
reviewer opens /admin/queue?type=verification
→ apps/api /admin/queue
SELECT listing_versions
WHERE review_state='pending'
ORDER BY submitted_at ASC ← FIFO for fair SLA
+ join photo grades, agent NoC, wallet history
→ reviewer picks one, opens A03 verification-detail
→ reviewer sets:
- reviewer_title (crafted from structured fields)
- polygon_id (from library or draws new — kicks off polygon-create sub-flow)
- visibility_mask (per-field overrides on top of CATALOG defaults)
- decision: approve | needs_changes | reject
→ POST /admin/listings/:id/approve
begin transaction
UPDATE listing_versions SET review_state='approved', reviewed_by, reviewed_at
UPDATE listings SET
state='live',
published_at=now(),
expires_at=now()+30d,
reviewer_title, polygon_id, visibility_mask,
current_version_id
INSERT audit_events(actor=reviewer, action='approve', target, before, after)
commit
→ enqueue notify(seller: "listing live")
needs_changes path:
UPDATE listing.state='needs_changes' + note-list
→ notify seller with the note list (S14)
reject path:
UPDATE listing.state='removed_by_admin'
wallet stays debited (fee is for review, not for approval)
audit_events records reason
Invariants
- Reviewer decisions are always via
/admin/*handlers — never direct DB. - SLA is measured from
submitted_attoreviewed_at, target 48h (see CATALOG § Verification SLA). - Every field on
visibility_masktoggle is logged inaudit_events.before/after— a reviewer who over-shares can be identified.
5. Complaint → refund → optional kill-listing
Screens: S38 (buyer) → A04 → A05 → refund + optional listing removal
buyer files complaint (from S34b or S38)
→ apps/mobile → POST /complaints { unlock_id?, listing_id, severity, body }
→ apps/api:
INSERT complaints(filer_id, severity, state='open', ...)
INSERT audit_events(actor=buyer, action='file_complaint')
→ if severity in ('NCDRC','DPDP','RERA'): enqueue notify(admin_pager)
→ return 201
admin opens /admin/complaints/:id (A05)
→ chooses action:
- refund: as in §2 refund path
- kill_listing: state='removed_by_admin', notify seller, no wallet impact
- warn_user: adds warning row to user profile (visible on A07)
- none: mark 'dismissed' with reason
→ POST /admin/complaints/:id/resolve { action, notes }
begin transaction
UPDATE complaints SET state='resolved', resolved_at, action_taken, assigned_to
(execute action side-effects — refund and/or kill in same tx)
INSERT audit_events(actor=admin, action='resolve_complaint', ...)
commit
→ notify(buyer + seller if kill)
SLA (from CATALOG)
- Acknowledge < 72 hrs (auto-ack email on POST /complaints).
- Action < 7 days.
- Refund processing 7 days from documented complaint.
Regulatory backstop
- Complaints with severity in ('NCDRC','DPDP','RERA') cannot be dismissed without a reason string and a linked grievance-officer email thread. Enforced in handler.
What's deliberately NOT flowed here
- Auth / OTP — standard phone-OTP via Supabase Auth; documented in step-4 tech stack.
- Recharge — standard Razorpay Payment Link → webhook → credit ledger. See flow 2 for the
RECHARGE_REQUIREDhandoff. - Photo upload — covered inside flow 1 step 3; no separate flow.
- Favorites, filters, notifications, settings — CRUD, no invariant risk.
- Polygon creation — reviewer sub-flow inside A03; treated as an operational tool, not a load-bearing user flow.