Changelog
Feature changelog for the Seken platform, web, mobile, and backend. Most recent entry first. Updated after every feature ship, fix, or meaningful change.
For incidents and bug fixes with root-cause analysis, see Error Log →.
Format​
## YYYY-MM-DD — Feature Name
**Type:** Feature / Fix / Improvement / Security
**Affected:** Web / Mobile / Ops / All
**Summary:** One sentence describing what changed.
**Details:** Optional — link to PR or more context.
2026-06-03, Hardening: Seller withdrawal audit remediation (PS-890-899)​
Type: Fix / Security Affected: Backend, Web, Mobile, Cuan Summary: Eight fixes from a cross-stack audit of the seller withdrawal / Dompet wallet flow, closing a concurrency double-spend, a phantom-credit path, a stuck "processing" label, and hardening the payout path; plus cuan monitor-side reconciliation and out-of-order safety. Details:
- PS-890: Concurrency lock: a partial unique index (
uq_seller_withdrawals_active_per_seller) plus a row-insert reservation gate before the balance hold means a seller can have only one in-flight withdrawal; concurrent duplicates get409 WITHDRAWAL_IN_PROGRESSinstead of double-spending the balance. Migration + rollback. - PS-891:
appendLedgerEntrynow rethrows instead of swallowing DB errors; the withdrawal reversal only fires for a confirmed hold, eliminating a phantom-credit path that could inflate a balance. - PS-892: On a paid payout the hold ledger entry is relabelled
withdrawal_hold→withdrawal_debit(balance-neutral), so the Dompet history shows the withdrawal as settled ("Penarikan") instead of "diproses" forever. - PS-893: The Xendit payout fee (
PAYOUT_LINK_FEE_IDR) is now an environment secret instead of a hardcoded0; the web and mobile withdraw flows display the real fee and net amount the seller receives. (Go-live: set the secret to the real fee.) - PS-898: Withdraw-path hardening: request-body validation schema, a boot-time guard for the Xendit secret key, an idempotency key on the payout-link call, and an email pre-check before any balance hold.
- PS-899: Withdraw UX: web popup-block honesty + balance-load error states; mobile zero-balance button disable, brand-palette fix, and a distinct transaction-load-error state; both surface the withdrawal ID and refresh the transaction list after a withdrawal.
- PS-896 / PS-897 (Cuan): The finance monitor now records when a mirror balance debit is skipped (
balance_debit_applied/recon_note) and a new reconciliation check surfaces withdrawal drift; out-of-order withdrawal events no longer zero the mirror's financial fields. Cuan remains monitor-only.
2026-06-02, Security: Cuan disbursement execution disabled, monitor-only (PS-895)​
Type: Security
Affected: Cuan
Summary: The Cuan finance portal no longer executes seller payouts; the main marketplace owns payouts via Xendit Payout Link, so Cuan running disbursements in parallel was a double-pay risk.
Details: A default-OFF kill-switch (CUAN_DISBURSEMENT_ENABLED) guards the disbursement worker and the Xendit send chokepoint, both no-op/throw unless the secret is explicitly "true". The variable is unset in production, so execution is off. Monitoring and mirroring are unaffected. Reversible by config; no migration.
2026-06-02, Feature: Open the related order from a Dompet transaction (PS-887, PS-888, PS-889)​
Type: Feature
Affected: Backend, Web, Mobile
Summary: Tapping an order-linked wallet transaction now opens that order's detail.
Details: GET /orders/:id now accepts the human-readable ORD-… order reference in addition to the UUID (PS-887), so the clients can open an order using the reference shown on the transaction row. Web: the order reference becomes a link that opens the order in the seller-orders view (PS-888). Mobile: the reference line is tappable and pushes the order detail screen, fetching by reference when not cached (PS-889). Payout-side rows (no order) stay non-interactive.
2026-06-02, Feature: Order reference shown per Dompet transaction (PS-885, PS-886)​
Type: Feature
Affected: Web, Mobile
Summary: Each transaction in the Dompet "Riwayat Transaksi" list now shows which order it came from.
Details: The wallet transaction row renders the human-readable order reference (ORD-…) on its own line for order-linked transactions (sale proceeds, refunds, clawbacks); payout-side rows (holds/reversals) show none. Both web (PS-885) and mobile (PS-886) fixed the same latent defect where the reference was truncated or hidden behind the description. The reference was already returned by GET /wallet/transactions, no backend change.
2026-06-02, Fix: Tarik Dana (withdraw) modal transparent in dark mode (PS-884)​
Type: Fix
Affected: Web
Summary: The seller "Tarik Dana" withdraw modal rendered with a near-transparent panel in dark mode; it now uses an opaque dark surface like every other modal.
Details: The panel used the semi-transparent bgCard token (5.5% opacity in dark mode, designed to float over the solid page) over the modal's dark scrim, compositing see-through. Fixed to use the opaque charcoal bgAlt token in dark mode, matching the established modal pattern. One-line change in SellerDashboard.tsx; light mode unchanged. React web only, the Flutter withdraw sheet was already opaque. PRs #1432 (develop) / #1433 (main).
2026-06-02, Feature: primary-address phone auto-fills profile phone (PS-883)​
Type: Feature
Affected: Backend
Summary: A user's primary shipping address phone now back-fills user_profiles.phone automatically, fill-in only, never overwriting a phone the user set themselves.
Details: On any primary-address mutation (create / edit / set-primary / delete-and-re-promote), the primary address's phone populates user_profiles.phone only when that field is currently empty, normalized to +62 form. Best-effort side effect (never aborts the address write). Reduces how often the "phone required for shipping" gate fires at listing/checkout. Backend only, no migration. PRs #1430 (develop) / #1431 (main).
2026-05-16, Feature: Cuan Bidirectional Communication Layer (PS-454-PS-481)​
Type: Feature Affected: Backend (pasarseken + pasarseken-cuan), Ops portal, Cuan portal Summary: Full bidirectional communication layer between the SEKEN marketplace and the Cuan finance portal, outbox pattern in both directions, synchronous command layer, query proxy, and monitoring UI on both portals. Details:
- Type 1 events (main → cuan): All order lifecycle events and boost purchases are written to
cuan_outbox_eventsand delivered to cuan via thecuan-deliverycron edge function (every 15s, up to 5 retry attempts per event). Cuan updatesfin_ordersandfin_seller_balances. - Type 1 events (cuan → main): Disbursement approved/blocked and fraud flag raised events flow from cuan's
fin_outbox_eventsvia thecuan-to-main-deliverycron. Main creates notifications and flags sellers for ops review. - Type 2 commands: Finance admin can resolve disputes from cuan's new DisputesPage. The
FinCommandClientsends a synchronous command toPOST /internal/commands/resolve-disputeon main, which runs theresolve_dispute_atomicRPC and fires buyer/seller emails. - Type 3 query proxy:
GET /finances/sellers/:id/balanceon main proxies to cuan for live data, with Deno KV as stale fallback. - Monitoring:
CommsPagein cuan (3 tabs: Outbound, Inbound, Commands);CuanOutboxMonitorin SEKEN ops portal (superadmincuan-commstab). - All 28 Jira tickets (PS-454-PS-481) merged to main on both repos.
2026-05-15, Fix: fin_boost_purchases missing columns, boost upsert broken (PS-448)​
Type: Fix
Affected: Backend
Summary: Every boost purchase upsert was failing in production (216 errors) because fin_boost_purchases was missing two columns that the code always writes.
Details: The original p4_ops_admin_schema.sql migration created fin_boost_purchases without expires_at or listing_title. The route handler at routes-finances.tsx:1641 writes both fields on every boost initiation. Migration 20260515000004_fin_boost_purchases_add_expires_at.sql adds expires_at TIMESTAMPTZ (nullable) and listing_title TEXT (nullable) via ADD COLUMN IF NOT EXISTS. PRs: #806 (→ develop), #807 (develop → main).
2026-05-15, Fix: create live_streams and live_chat_messages tables (PS-447)​
Type: Fix
Affected: Backend
Summary: public.live_streams and public.live_chat_messages did not exist in production, live stream feature was throwing schema cache errors on every call.
Details: Both tables were defined in the 20260425000003_kv_migration_tables.sql batch (26 KB, partial application failure) but never created. Migration 20260515000003_create_live_streams.sql creates both. live_streams: id TEXT PK (LS + 12 hex, not UUID), status CHECK (live/upcoming/ended), pinned_listing_ids TEXT[], viewer counts, seller FK, timestamps. live_chat_messages: stream_id FK → live_streams, user_id TEXT (allows "system" sender), type CHECK (message/offer/system). PRs: #803 (→ develop), #804 (develop → main).
2026-05-15, Fix: create hiring_jobs table (PS-446)​
Type: Fix
Affected: Backend
Summary: public.hiring_jobs did not exist in production, all hiring job routes were failing.
Details: Same root cause as PS-445 and PS-447 (20260425000003 partial failure). Migration 20260515000002_create_hiring_jobs.sql creates the table with 32 columns: bilingual _id/_en pairs for title, team, location, type, salary, company, badge, description; TEXT[] arrays for responsibilities, requirements, nice-to-have, perks; is_active BOOLEAN DEFAULT true; soft-delete. Must be applied before hiring_applications so the FK resolves. PRs: #801 (→ develop), #802 (develop → main).
2026-05-15, Fix: create hiring_applications table (PS-445)​
Type: Fix
Affected: Backend
Summary: public.hiring_applications did not exist in production, SupabaseHiringRepository.findAllApplications was throwing schema cache errors on every call.
Details: Table was defined in 20260425000003_kv_migration_tables.sql but never applied. Migration 20260515000001_create_hiring_applications.sql creates it with 18 columns: job_id UUID FK → hiring_jobs, status CHECK (new/reviewed/shortlisted/rejected/hired), attachments JSONB DEFAULT '[]', bilingual contact fields, deleted_at soft-delete. RLS: SELECT for ops_admin/superadmin; INSERT/UPDATE/DELETE service_role only. PRs: #799 (→ develop), #800 (develop → main).
2026-05-14, DB audit: price-drop fix, dead inserts removed, 7 migrations (PS-434-PS-444)​
Type: Fix / Improvement / Infra Affected: Backend Summary: Full migration-vs-code audit surfaced 3 P0 issues and 8 schema gaps, all resolved across 11 tickets merged to main.
Details:
- PS-434 (Fix):
routes-listings.tsx:1118called.from("favorites"), a table that doesn't exist. The canonical table islisting_likes. Price-drop notification emails were silently never sent. Fixed to.from("listing_likes"). - PS-435 (Fix):
ai_ops_assistant.tsx:709inserted intoai_interactions(table never migrated). Error was swallowed, ops assistant worked but produced no audit trail. Dead insert removed. - PS-436 (Fix):
ai_ops_assistant.tsx:691inserted intosecurity_events(table never migrated). Same silent failure pattern. Dead insert removed. - PS-437 (Infra):
returnstable documented as dormant. Admin return routes operate onorders.order_status(RETURN_REQUESTED / RETURN_APPROVED / RETURN_RECEIVED). - PS-438 (Infra):
email_indextable documented as dormant, never read or written by any app code. - PS-439 (Infra): Indexes added on
referrals(referrer_id)andreferrals(buyer_id). Migration20260514000003. - PS-440 (Infra):
user_affinity_profiles.user_idconfirmed as PK, auto-indexed, no migration needed. - PS-441 (Infra): Index
broadcasts(status, created_at DESC)added. Migration20260514000005. - PS-442 (Infra):
campaigns.seller_id UUID FKcolumn + indexes on(seller_id)and(status, created_at DESC). Migration20260514000006. - PS-443 (Infra):
support_macros.is_active BOOLEAN NOT NULL DEFAULT TRUE+ index on(category, is_active). Migration20260514000007. - PS-444 (Infra): KV-backed tables (
ab_tests,email_templates,webhook_events) annotated in migrations as tech debt.
PRs: #787-#797 (each ticket → develop), #798 (develop → main).
2026-05-10, Flutter tracking sheet: stepper removed, status labels completed (PS-256)​
Type: Fix / Improvement
Affected: Mobile
Summary: The Flutter order detail tracking sheet no longer shows a fixed 4-step stepper; all 13 Biteship status codes now have correct Indonesian labels.
Details: The 4-step progress stepper (Pesanan Diproses → Dikemas → Dikirim → Diterima) was a hardcoded visual that did not reflect real-time Biteship status, already removed from the React tracking modal in PS-254. PS-256 deletes the equivalent Flutter code: _buildStepper() method, _stepLabels/_stepStatuses/_currentStepIndex fields, the call site, and its spacer (net −66 lines). _statusLabels extended from 4 to 16 entries covering all Biteship statuses (confirmed, allocated, picking_up, picked_up, picked, dropping_off, in_transit, out_for_delivery, on_hold, return_in_transit, returned, delivered, rejected, cancelled, disposed, courier_not_found) with the same Indonesian descriptions used in the React modal. flutter analyze: 0 errors, 0 warnings.
2026-05-10, Detail Pengiriman: auto-refresh every 10 s​
Type: Feature + Fix
Affected: Web
Summary: The Detail Pengiriman tracking panel now auto-fetches on open and auto-refreshes every 10 seconds, with a live countdown on the Refresh button.
Details: Panel immediately fetches live Biteship data when opened (no manual Refresh needed). A 1 s tick interval counts down from 10 and fires a refresh at zero using an inFlight guard to prevent concurrent calls. An 8 s safety timeout releases the lock if the API hangs, ensuring the auto-refresh loop is never permanently blocked. Refresh button shows Refresh (Ns) countdown; clicking it forces an immediate fetch and resets the countdown. Removed the 4-step progress bar (replaced by full timeline in PS-255). Local state resets on modal close so each open starts clean.
2026-05-10, Fix: listing status never updated to "sold" after order completion​
Type: Fix
Affected: Backend, Web
Summary: Listings stayed in "reserved" status permanently instead of changing to "sold" after a buyer paid, caused by a mistyped return value in findByOrderIdRaw.
Details: SupabaseOrderRepository.findByOrderIdRaw returned an Order domain entity (camelCase getters like .listingId) instead of a KV-compatible Record<string, unknown> (snake_case .listing_id). Every access to order.listing_id in triggerPaymentConfirmedSideEffects evaluated to undefined, so the mark-sold block was silently skipped on every payment confirmation since the DDD migration. Fix: rewrote findByOrderIdRaw to use SELECT *, listings(...) + orderRowWithListingToKV(), identical pattern to findByIdRaw. Also added a fallback in PUT /orders/:id/confirm (Konfirmasi Diterima) that marks the listing as sold if it is still reserved at receipt-confirmation time, covering historical orders that missed the payment-time update.
2026-05-08, Flutter Vibe Trending chips: end-to-end vibeTag filter (PS-243)​
Type: Bug Fix / Feature
Affected: Mobile, Backend
Summary: Vibe Trending chips on the Flutter home screen now correctly filter listings by exact vibe tag, previously all chips returned empty results due to a broken text-query approach.
Details: The original implementation passed the chip label (e.g. '🔥 Hypebeast Indo') as a free-text initialQuery to the search screen. This failed two ways. First, the search_listings RPC sanitiser strips all non-alphanumeric characters, K-Style became kstyle:* as a tsquery term, but the tsvector stores k and style as separate tokens (the hyphen is a word boundary), so kstyle:* matched nothing. Second, chip labels such as Cewek Bumi, Vintage Vibes, and Minimal Fits have no corresponding stored vibe_tags values in the database. Fix spans all layers: (1) new DB migration 20260508000002 adds p_vibe_tag TEXT DEFAULT NULL to the search_listings RPC with a = ANY(l.vibe_tags) filter in the filtered CTE; (2) SearchQuerySchema gains a vibe_tag field; (3) the /search route extracts vibe_tag and passes it to searchListingsRaw; (4) vibeTag is threaded through the full Flutter Clean Architecture stack, SearchListingsParams → SearchListings use case → ListingRepository interface → ListingRepositoryImpl → ListingRemoteDataSourceImpl (sent as ?vibe_tag= query param) → ListingsProvider.search(); (5) SearchScreen gains a vibeTag field and fires _performSearch('') on mount when vibeTag is set without initialQuery; (6) HomeScreen now passes vibe.$2 (label without emoji) as vibeTag instead of embedding the full emoji+label string in initialQuery. The provider empty-query guard was also updated to treat vibeTag as an active filter, preventing premature result clearing. 15 files changed. flutter analyze: 0 errors, 0 warnings. Commit 1babb3e, branch feat/PS-243-vibe-tag-filter.
2026-05-08, Flutter Cari screen: product category filter wired (PS-242)​
Type: Feature
Affected: Mobile
Summary: The Cari (search) screen filter sheet now includes a Kategori Produk chip section, and the selected category is passed through to the backend search API.
Details: The productCategory parameter was already fully wired in the provider, use case, datasource, and backend, but search_screen.dart never populated or passed it. Three coordinated changes in a single file: (1) FilterResult data class gained a productCategory field; (2) _FilterSheet and _FilterSheetState were updated with a selectedProductCategory constructor param, _productCategory local state variable, initialisation in initState(), clearing in _reset(), and inclusion in _apply(); (3) a new "Kategori Produk" chip section was added as the first section in the filter sheet using Categories.productCategories from constants.dart, 8 chips: Atasan, Bawahan, Dress, Outerwear, Sepatu, Tas, Aksesoris, Activewear; (4) _SearchScreenState gained _selectedProductCategory state, passes it to _performSearch(), receives it in _showFilterSheet() onApply, and includes it in both the hasFilters callback check and the hasActiveFilters filter-indicator dot check. flutter analyze: 0 errors, 0 warnings. Commit 7e0f61c, branch feat/PS-242-cari-product-category-filter.
2026-05-08, Flutter Cari screen: filter-only browsing now works (PS-241)​
Type: Bug Fix
Affected: Mobile
Summary: Opening the Cari filter sheet, selecting filters, and tapping "Terapkan Filter" with an empty search box now correctly fires a search and returns filtered results.
Details: Two separate guards blocked filter-only browsing and both had to be fixed. Screen layer (search_screen.dart): the onApply callback guarded _performSearch with if (_searchController.text.isNotEmpty), when the search box was empty, the callback returned early, leaving the screen blank with no feedback. Fixed by adding a hasFilters boolean (checking gender, condition, brand, sizes, price range, sort) so _performSearch fires when any filter is active regardless of the text query. Commit 69b1ea8. Provider layer (listings_provider.dart): ListingsProvider.search() had its own early-return guard if (query.trim().isEmpty) { _searchResults = []; return; } that cleared results and bailed before any API call, so even after the screen-layer fix the search was discarded in the provider. Fixed by changing the condition to if (query.trim().isEmpty && !hasFilters), a hasFilters check mirroring the screen-layer logic. Commit e2b5455. Both commits on branch fix/PS-241-cari-filters-empty-query; the provider fix was also cherry-picked onto feat/PS-242-cari-product-category-filter.
2026-05-08, Flutter Cari screen: search results always returned empty (PS-240)​
Type: Bug Fix
Affected: Mobile
Summary: Every search on the Cari screen returned zero results regardless of query or filters because the search repository was reading the wrong JSON key from the API response.
Details: ListingRepositoryImpl.searchListings() in listing_repository_impl.dart read data['items'] from the /search API response, but the backend returns search results under the key "listings" (established with PS-233). The ?? [] fallback silently produced an empty list on every call, no errors, no logs, just zero results. Fix: one-line change, data['items'] → data['listings']. Note: the test mock in listing_repository_impl_test.dart was also returning {'items': [], 'total': 0} with the wrong key; corrected to {'listings': [], 'total': 0} in the same fix. flutter analyze: 0 errors, 0 warnings. Commit f6a8a1c, branch fix/PS-240-search-empty-wrong-json-key.
2026-05-08, Flutter home: category filter now works correctly (PS-239)​
Type: Bug Fix
Affected: Mobile
Summary: The home screen category filter in the Flutter app was silently dropped, selecting a category returned the same full unfiltered feed. Fixed by correcting the query parameter name passed through ListingsProvider.
Details: Two bugs in mobile/lib/features/listings/presentation/providers/listings_provider.dart worked together to discard the user's category selection. Bug 1: fetchListings() passed category: category to GetListingsParams, which sent ?category=tops to the backend. The backend's PS-233 browse path (GET /listings) only reads ?product_category=X via its Zod schema; the category param was accepted but never fed into applyBrowseFilters, making every category selection a no-op. Bug 2: the stored filter field _activeCategory was forwarded as category: in every subsequent loadMore(), setSortBy(), and refresh() call, so the filter would have remained broken on page 2+ even if Bug 1 had been fixed. Fix: renamed _activeCategory → _activeProductCategory; changed category: category → productCategory: category in the GetListingsParams(...) call inside fetchListings(); changed category: _activeCategory → productCategory: _activeProductCategory in loadMore(). setSortBy() and refresh() delegate to fetchListings() and are automatically fixed. No changes to the filter sheet, use case, repository, datasource, or backend. flutter analyze: 0 errors, 0 warnings. Commit 7c8a79a, branch fix/PS-239-flutter-home-filter.
2026-05-08, Listings: paginated response envelope for GET /listings (PS-233)​
Type: Feature
Affected: Backend, Web, Mobile
Summary: GET /listings now returns a { listings, total, offset, limit } envelope instead of a bare array, enabling proper pagination and total-count display.
Details: Breaking change. Before PS-233, GET /listings returned a raw KVListing[]. After PS-233, the response is an object: listings (the page of results), total (total matching rows across all pages), offset, and limit. Four new query parameters are supported: limit (default 50), offset (default 0), gender, product_category, and sortBy (newest | lowest_price | highest_price). hasMore is not returned, callers compute it as offset + listings.length < total. All first-party clients (web and Flutter) updated in this ticket. Third-party integrations treating the response as a direct array must update their parsing. See API Endpoints → for the updated response schema.
2026-05-08, Boosted listings: stable pagination for GET /listings/boosted (PS-234)​
Type: Feature
Affected: Backend, Web, Mobile
Summary: GET /listings/boosted now supports stable, paginated results, replacing a non-deterministic single-page shuffle with a consistent score-descending sort and { listings, total, offset, limit } response envelope.
Details: Breaking change. Before PS-234, GET /listings/boosted shuffled results randomly on every call and returned { boosted, total, promoted_count, organic_count } with no pagination support. After PS-234: the response key boosted is renamed to listings; promoted_count and organic_count are removed; total now represents the full pool size before slicing (not the page length); and two new query parameters are accepted, limit (integer, default 24, max 50) and offset (integer, default 0). Sort order is stable: score descending, with listing id as the tiebreaker, so repeated calls with the same parameters return the same results. hasMore is not returned, callers compute it as offset + listings.length < total. All first-party clients (web and Flutter) updated in this ticket. Third-party integrations must rename response.boosted to response.listings and remove any reads of promoted_count or organic_count. See API Endpoints → for the updated response schema and query parameter reference.
2026-05-08, React home: infinite scroll for listings and boosted listings (PS-235)​
Type: Feature
Affected: Web
Summary: The React home page now loads listings and boosted listings via infinite scroll driven by an IntersectionObserver sentinel, replacing the manual "Muat Lebih Banyak" load-more button.
Details: Consumer-side only, no new backend endpoints. api-marketplace.ts adds ListingsPageResponse { listings, total, offset, limit } to type the PS-233 paginated envelope, and updates BoostedListingsResponse to use the listings key (PS-234), removing the now-deleted promoted_count and organic_count fields. use-boosted-listings.ts reads .listings from the boosted endpoint response and exposes loadMore(), isLoadingMore, and hasMore (derived as total > 0 && offset < total). use-listings.ts unwraps the GET /listings paginated envelope internally, existing callers are unchanged and still receive a flat KVListing[]. DesktopHome.tsx replaces the load-more button with a zero-height sentinel div watched by an IntersectionObserver (rootMargin: "200px"); when the sentinel enters the viewport, loadMore() fires; a Loader2 spinner is shown while isLoadingMore is true; the observer is disconnected once hasMore is false. Flutter parity gap: the Flutter boosted datasource still reads the old boosted key, tracked in PS-236. Branch: feat/PS-235-react-infinite-scroll.
2026-05-08, Flutter home: infinite scroll + API contract fixes (PS-236)​
Type: Feature
Affected: Mobile
Summary: The Flutter home screen now supports infinite scroll, and the Flutter datasource layer is updated to consume the PS-233 and PS-234 paginated API envelopes correctly, closing the Flutter parity gap flagged in PS-235.
Details: Consumer-side only, no new backend endpoints. Two new types are introduced: ListingsPage (datasource boundary: { listings, total, offset, limit }) and ListingsPageResult (domain layer, Equatable) which wraps ListingsPage for use in providers and use cases. API contract fixes: getListings now unwraps response['listings'] and response['total'] from the PS-233 envelope (was reading a bare array); getBoostedListings now reads the listings key (was boosted, the PS-234 breaking change) and removes reads of the deleted promoted_count and organic_count fields. Offline cache fallback synthesises total = cached.length so hasMore evaluates to false when offline. Infinite scroll: ListingsProvider._hasMore is computed as _offset < page.total (replaces the broken length >= pageSize check); isLoadingMoreError flag is set on a failed loadMore() call and reset on the next retry attempt; the scroll listener skips loadMore() while isLoadingMoreError is active to prevent hammering a failing endpoint. home_screen.dart footer renders a spinner while loading, a "Tap untuk coba lagi" retry button on error, and nothing when idle or fully loaded. 3 test files updated; flutter analyze passes with 0 errors and 0 warnings. Branch: feat/PS-236-flutter-infinite-scroll. This completes the full pagination feature end-to-end: PS-233 (backend /listings), PS-234 (backend /listings/boosted), PS-235 (React web), and PS-236 (Flutter mobile) are all shipped.
2026-05-07, Orders: "Cetak Label" button for sellers on web (PS-210)​
Type: Feature
Affected: Web
Summary: Sellers can now print their Biteship shipping label directly from the order detail screen on web when an order is in READY_TO_SHIP state.
Details: DesktopOrders.tsx, a "Cetak Label" SekenButton (ghost variant, teal #006D77, Printer icon) is rendered inside the READY_TO_SHIP seller action block. First click calls POST /shipping/waybill (idempotent, PS-208 guard prevents duplicate Biteship order creation), caches label_url in component state, and opens the PDF in a new tab. Subsequent clicks on the same order open the cached URL without a re-fetch. Switching to a different order resets the cache. If Biteship has not generated a label, a toast "Label belum tersedia" is shown. api-marketplace.ts, label_url?: string | null added to createWaybill() return type. pnpm tsc --noEmit clean. Flutter parity: PS-211 (Flutter "Cetak Label" action) is pending. Branch: feature/PS-210-cetak-label-react (commit 9d0177a). Depends on PS-208.
2026-05-07, Shipping: extract and persist Biteship label_url (PS-208)​
Type: Feature
Affected: Backend
Summary: The Biteship create-order API returns a label_url (printable PDF) that was previously discarded; it is now extracted, stored in shipments.label_url, and returned by POST /shipping/waybill.
Details: Five files changed. BiteshipAdapter.ts, label_url?: string | null typed on BiteshipOrder; createBiteshipOrder() return typed concretely (was Promise<unknown>). SupabaseShipmentRepository.ts, label_url added to COLUMN_MAP and toRow(). shared-business.tsx, autoCreateBiteshipOrder persists label_url via shipmentRepo.patchForOrder (non-fatal). routes-shipping.tsx, POST /shipping/waybill now includes label_url in the JSON response and persists it fire-and-forget. Migration 20260507000001_shipments_add_label_url.sql adds a nullable TEXT column to shipments, no backfill. label_url is a new additive field in the waybill response; no existing client code breaks. Next: PS-210 (React "Cetak Label" button) and PS-211 (Flutter "Cetak Label" action) will surface the URL to sellers. Branch: feature/PS-208-persist-label-url (commit abf7ff0).
2026-05-07, Payments: auto-create Biteship order on invoice PAID (PS-159)​
Type: Feature
Affected: Backend
Summary: When a Xendit invoice transitions to PAID, the backend now automatically creates a Biteship shipping order and stores the resulting biteship_order_id in orders.shipping_details.
Details: autoCreateBiteshipOrder(order) is called fire-and-forget on both the Xendit webhook PAID path and the /payments/:orderId/check poll PAID path in routes-orders.tsx. The function (shared-business.tsx) guards against: duplicate creation (skips if biteship_order_id already set), missing courier_code (PS-158 dependency), and incomplete seller/buyer shipping addresses (street/postal_code required). The Biteship payload uses courier_code/courier_service_code from orders.shipping_details (written by PS-158) and listing.weight_grams (written by PS-161). Any Biteship failure is warn-logged to Sentry and swallowed, the order always reaches READY_TO_SHIP regardless of outcome. Follow-on: add shipper_organization: "SEKEN Marketplace" to the payload for org name parity with the manual waybill path. Branch: fix/PS-159-auto-create-biteship-on-paid (commit 57f50fe). Dependencies: PS-158 (courier codes on order) and PS-161 (weight_grams on listing) must be merged before this branch ships.
2026-05-06, Checkout: store courier code and service code on order (PS-158)​
Type: Fix
Affected: Backend
Summary: courier_code and courier_service_code from the buyer's verified shipping quote were silently discarded at checkout; both are now persisted in orders.shipping_details.
Details: Root cause: routes-orders.tsx extracted only shipping_fee from the HMAC-verified quote payload, courier_code and courier_service_code were never passed downstream. The fix threads both fields through the full DDD chain: CheckoutFacade → CreateOrderUseCase → OrderFactory.toRow writes them to shipping_details JSONB; OrderFactory.fromDbRow now reads them back into the Order domain entity. No DB migration required, the fields land in the existing shipping_details JSONB column on orders. All new fields are optional so existing orders without these keys are handled safely. Follow-on: the READY_TO_SHIP → SHIPPED transition (Biteship waybill creation) should read courier_code/courier_service_code from orders.shipping_details to book the correct courier service. Branch: fix/PS-158-store-courier-codes-on-order (commit 5f7653b).
2026-05-04, Recently sold feed: live completed-order data (PS-143)​
Type: Feature
Affected: Web, Backend
Summary: The recently sold feed on the homepage now shows real completed orders instead of hardcoded placeholder items.
Details: Backend adds a public GET /feed/recently-sold?limit=20 endpoint (max 50 items, no auth required). The handler uses getRecentlySold(limit) on SupabaseOrderRepository, a CQRS raw-read helper that joins orders (status COMPLETED or DELIVERED) to listings and user_profiles, ordered by completion timestamp descending. Returns { success: true, data: [] } when no completed orders exist, never seeded data. React replaces the hardcoded feed with a RecentlySoldFeed horizontal carousel (React Query, stale 60 s) with shimmer skeleton loading, a Terjual badge, and Indonesian empty state copy. Flutter parity is tracked in PS-147. Branch: feature/PS-143-recently-sold-feed.
2026-05-04, Homepage: live founding-seller count and earnings disclaimer (PS-142)​
Type: Fix
Affected: Web
Summary: The pre-launch homepage no longer shows a hardcoded founding-seller count (23), it now queries the real number of registered sellers and derives available seats dynamically; the earnings calculator now carries an illustrative disclaimer.
Details: PreLaunchLanding.tsx replaces const foundingSellers = 23 with a useEffect that runs a Supabase count query against user_profiles where is_seller = true on mount. All three AnimatedCounter usages (foundingSellers, seatsLeft x2) show — while the query is in flight; if the query fails, — persists, no stale value is ever shown. DesktopHeroSection.tsx adds an italic footnote below the earnings calculator grid using the new hero.earnings.disclaimer i18n key ("Estimasi ilustratif, hasil aktual dapat berbeda" / "Illustrative estimate, actual results may vary"). Branch: fix/PS-142-remove-hardcoded-social-proof.
2026-05-04, Social proof: remove hardcoded fabricated signals (PS-141)​
Type: Fix
Affected: Web
Summary: Four fabricated social-proof signals on the listing detail page and live stream components have been removed, buyers were being shown fake viewer counts, seeded chat messages, and a hardcoded community count popup.
Details: getSocialProof() in social-proof.ts generated savedCount (0-12) and viewersToday (3-45) via a Mulberry32 PRNG seeded from the listing ID. DesktopLiveStreams.tsx had a setInterval injecting 5 fake messages with fictional usernames (thriftlover_id, fashionista99, etc.). SocialProofPopup.tsx initialized with count: 3247 and growthPct: 12 before any server response. Fixes: savedCount now reads listing.likes (maintained by the bump_listing_like_count DB trigger); viewersToday removed (no real per-day view tracking exists); fake chat setInterval replaced with GET /live-streams/:id/chat polling and POST /live-streams/:id/view for real viewer count; SocialProofPopup renders null until the server returns a verified positive count. No new backend endpoints required, all three live-stream endpoints already existed. Branch: fix/PS-141-remove-hardcoded-social-proof.
2026-05-03, Infrastructure: search_terms table exposed to PostgREST (PS-137)​
Type: Fix
Affected: Backend
Summary: Trending search suggestions were silently broken, SupabaseSearchTermRepository.findTrending threw a schema cache error on every call because the search_terms table was missing from production despite being defined in code.
Details: Sentry issue #117067953 (3 users). Root cause: 20260425000003_kv_migration_tables.sql (26 KB batch) did not fully apply to production, search_terms was never created. New idempotent migration 20260503114936_ensure_search_terms_postgrest.sql creates the table with RLS and calls pg_notify('pgrst', 'reload schema') to force immediate PostgREST cache reload. RowSearchTerm interface added to row-types.ts; duplicate local definition removed from SupabaseSearchTermRepository.ts. Branch: fix/ps-137-search-terms-postgrest.
2026-05-03, Boost: promotions.listing_id nullable for admin grants (PS-136b)​
Type: Fix
Affected: Backend
Summary: POST /admin/growth/boosts now correctly inserts to the promotions table, previously blocked by a NOT NULL constraint on listing_id that does not apply to seller-level (no-listing) admin grants.
Details: Migration 20260503120000 drops the NOT NULL constraint on promotions.listing_id and replaces it with a CHECK constraint: listing_id IS NOT NULL OR promo_type = 'boost'. All other promo types still require a listing_id. SupabasePromotionRepository.createBoost() added; routes-growth.tsx POST /admin/growth/boosts warning workaround removed. Branch: fix/PS-136b-boost-listing-id-nullable.
2026-05-03, Boost: fix stale kv_store reference in boost lookup (PS-136)​
Type: Fix
Affected: Backend
Summary: Boost lookups, listing feed ranking, and the seller dashboard boost check were silently broken, all three were querying a dropped database table (kv_store_e10b1b67) that was removed in the Phase 14 KV migration.
Details: Sentry error #117067954 (4 users). getByPrefixAll("boost:") callers in routes-listings.tsx and routes-growth.tsx rewrote to query the promotions table via SupabasePromotionRepository. Methods added: findAllBoostsRaw() and findActiveBoostsBySellerId(). Listing feed +60 score bonus for boosted sellers is restored. Branch: fix/PS-136-boost-kv-stale-prefix-query.
2026-05-03, Infrastructure: app_settings relational table (PS-135)​
Type: Fix
Affected: Backend
Summary: Created the missing public.app_settings table so SupabaseAppSettingsRepository can query app config without throwing a schema cache error.
Details: Migration 20260425000003_kv_migration_tables.sql included the DDL in a large batch that did not fully apply to production. New idempotent migration 20260503000001 creates the table with IF NOT EXISTS guards, seeds 4 config keys, applies service_role-only RLS, and notifies PostgREST to reload its schema cache immediately. The app_settings:global KV key is retired, config is now fully relational. Branch: fix/ps-135-app-settings-schema.
2026-05-03, AI listing: non-fashion image detection (PS-134)​
Type: Feature
Affected: Web, Mobile, Backend
Summary: The AI listing assistant now detects non-fashion photos before categorizing, if a seller uploads food, electronics, or furniture, they see a "Foto bukan produk fashion" alert with options to swap the photo or continue anyway.
Details: /ai/categorize updated to a discriminated union, single GPT-4o mini call, no extra latency. Non-fashion images return { is_fashion_item: false, non_fashion_reason_id, confidence, source }. Both React (CreateListingFlow.tsx) and Flutter (create_listing_screen.dart) show an alert dialog: "Ganti Foto" (primary, teal) resets the flow to the photo upload step; "Tetap Lanjut" (secondary) soft-gates the upload through. Branch: feature/PS-134-ai-fashion-detection-alert.
2026-05-02, Flutter AI listing: map missing fields (PS-132)​
Type: Fix
Affected: Mobile
Summary: Flutter listing creation now maps all major AI categorization response fields, style, sub-category, vibe tags, original price, and confidence warning, matching React parity.
Details: _uploadAndRunAiCategorization() in create_listing_screen.dart previously only assigned 7 of 20+ fields returned by /ai/categorize. The 4 new fields are now mapped into form state and included in the submit payload. An amber snackbar is shown when the AI confidence score is below 30%. Branch: fix/PS-132-flutter-ai-missing-fields.
2026-05-02, Flutter AI listing: fix stale gender field name (PS-131)​
Type: Fix
Affected: Mobile
Summary: Flutter AI categorization now correctly assigns gender, every AI-scanned listing was silently defaulting to 'women' due to a stale field name comparison.
Details: create_listing_screen.dart was comparing result['gender_id'] against 'M'/'F', but the backend returns a normalized gender field with values 'men'/'women'. The comparison never matched. Fix: read result['gender'] directly. Branch: fix/PS-131-flutter-ai-response-field-names.
2026-03-19, Developer documentation​
Type: Improvement
Affected: Ops
Summary: Created the full docs/developer/ section covering architecture, API endpoints, database, local setup, deployment, Flutter, Figma Make, environment variables, error log, and changelog.
2026-03-18, Founder Control Dashboard docs​
Type: Improvement Affected: Ops Summary: Added Founder Control Dashboard and CTO Guide documentation pages to the Operations section.
Initial Launch, Pasar Seken MVP​
Type: Feature Affected: All Summary: Initial MVP launch, C2C secondhand fashion marketplace with AI-powered listings, Xendit payments, Biteship shipping, KTP verification, and escrow buyer protection. Details: Core stack: Flutter 3.41 mobile app, React 18 web app, Hono on Supabase Edge Functions, KV store database pattern, GPT-4 Vision AI categorization.
Add new entries above this line, most recent first.