Database Schema
81 tables, 100% RLS coverage, DDD-organized. Migrated from KV store to fully relational. Every mutation routes through the NestJS backend (Drizzle over postgres.js), which owns validation and access control at the application layer.
Full migration-vs-code audit completed. 7 new migrations landed. Price-drop notification bug fixed (stale table name). Two dead insert calls removed. Schema gaps and orphan tables documented. All tables now have RLS.
Schema at a glanceโ
| Metric | Value |
|---|---|
| Total tables | 81 |
| Functional areas | 8 |
| RLS coverage | 100% |
| Ephemeral KV keys remaining | 3 (health-check, error, durable_rl) |
| Migration source | KV store (fully retired) |
| Last audit | 2026-05-14 (PS-434โPS-444) |
Tables by functional areaโ
Core marketplace tables (5)โ
The heart of the platform.
user_profilesโ
Every authenticated user has a row. Stores role, KTP status, account state, profile data.
Key columns:
id(uuid, FK toauth.users)email,username,name,avatar_urlrole(buyer, seller, ops_admin, finance_admin, superadmin)account_status(active, suspended, banned)ktp_status(none, pending, approved, rejected)phone(PII โ RLS restricts to own row)verified_seller(boolean โ gets badge)
listingsโ
Every seller-created item.
Key columns:
id,seller_id(FK to user_profiles)title,description,price,negotiablecategory,sub_category,brand,conditionimages(text array, up to 8 URLs)vibe_tags(text array)statusCHECK constraint:active, sold, draft, reserved, hidden, inactive, removed, archivedโarchivedis the soft-delete statemoderation_statusCHECK constraint:pending_review, approved, rejected, flagged. Default changed to'approved'for pre-launch auto-approve. New listings start asapprovedโ ops can retroactively flag or reject.version(optimistic locking)deleted_at(soft delete โ always accompanied bystatus='archived')boost_active,boost_type,boost_expires_at
ordersโ
The transactional spine. State machine lives here.
Key columns:
id,buyer_id,seller_id,listing_idorder_status(14 states, DB CHECK constrained)version(optimistic locking)item_price,buyer_protection_fee,shipping_fee,insurance_fee,payment_feetotal_paid(sum of all)xendit_invoice_id,xendit_paid_attracking_number,shipped_at,delivered_atdispute_window_start_at,return_window_end_atauto_confirm_at(delivered_at + 48h)consumed_at,consumed_by_order_id(atomic claim from chat offers)voucher_id TEXT DEFAULT NULL,voucher_discount NUMERIC(12,2) DEFAULT 0 CHECK >= 0โ discount recalculated server-side, capped to prevent zero/negative totals
cart_itemsโ
Buyer cart โ want-to-buy intent only, no reservation, no quantity (one listing = one item). Sold items stay in the cart (greyed out client-side).
Key columns:
id,user_id(FK toauth.users, cascade),listing_id(FK tolistings, cascade),added_atUNIQUE(user_id, listing_id); RLSauth.uid() = user_id
checkout_groupsโ
One per seller multi-item checkout โ unifies payment + shipping (one Xendit invoice per group).
Key columns:
id,buyer_id,seller_id,shipping_feestatusCHECK:pending_payment, paid, expired, cancelledxendit_invoice_id,xendit_invoice_url,xendit_expires_at,paid_atshipping_fee_released_atโ exactly-once shipping-fee-release marker
Social & messaging tables (4)โ
chat_threads, chat_messagesโ
1:1 chat between buyer and seller, scoped per listing.
reviewsโ
Post-completion reviews. Anti-gaming columns: is_suspicious, is_visible, flagged_reason. review_type TEXT NOT NULL DEFAULT 'listing' added (CHECK IN ('seller', 'listing')) โ backs the ops admin seller-vs-listing moderation split.
seller_scoresโ
Aggregate quality metrics per seller. Computed weekly.
User data tables (3)โ
addressesโ
Buyer shipping addresses. Multiple per user; one is flagged is_default.
PS-883 (2026-06-02): any primary-address mutation back-fills the primary address's phone into user_profiles.phone โ fill-in only, never overwriting a value the user set themselves.
bank_accountsโ
Seller payout destinations. Multiple per user but only one active.
ktp_verificationsโ
KTP submission history with OCR results, ops decisions, audit trail.
Promotions tables (3)โ
promotionsโ
Unified table for boosts and promo campaigns. promo_type column distinguishes.
notifications, notification_preferencesโ
In-app + push notifications + per-user channel preferences.
Finance tables (15)โ
All
fin_*tables live in thepublicschema withfin_prefix. Locked decision (2026-04-28) โ thefin_prefix provides sufficient logical separation pre-launch. A dedicatedfinancesschema may be introduced when cuan team separation triggers (post-launch, post finance-lead hire). See Cuan Portal future-state plan.
The full finance module:
fin_ordersโ order-level financial recordsfin_disbursementsโ payouts to sellers (with maker-checker)fin_seller_balancesโ escrow holdings per sellerfin_audit_logโ every financial change logged with before/after JSONBfin_xero_syncsโ Xero sync state (๐ก boolean only, no API call yet)fin_commission_rulesโ platform-side commission config (zero for sellers, BPF for buyers)fin_expensesโ operational expenses tracked per categoryfin_admin_rolesโ finance admin role assignmentsfin_boost_purchasesโ Boost product transactions. PS-448 (2026-05-15):expires_at TIMESTAMPTZandlisting_title TEXTadded โ both missing from original schema, causing 216 production errors on every boost upsert.fin_premium_purchasesโ premium feature transactionsfin_configโ finance module configurationfin_reconciliationsโ daily/monthly reconciliation runsfin_reportsโ generated financial reportsfin_report_schedulesโ scheduled report generationfin_payoutsโ payout batch tracking
Ops & admin tables (13)โ
Operational data for staff workflows:
support_ticketsโ user-submitted tickets (categories: account, payment, shipping, refund, seller, buyer, ktp, fraud, technical, other)support_macrosโ CS response macros used by the ops agent.is_active BOOLEAN NOT NULL DEFAULT TRUE; composite index(category, is_active). Added PS-443 (2026-05-14).vouchersโ promo codescampaignsโ marketing campaigns.seller_id UUID FK(nullable) added PS-442; indexes on(seller_id)and(status, created_at DESC).broadcastsโ push/email broadcasts. Index on(status, created_at DESC)added PS-441.returnsโ Dormant. Return flow usesRETURN_*states onorders. This table has no app writes. Pending clean-up. PS-437 (2026-05-14).payoutsโ manual payout recordsfraud_alertsโ flagged suspicious activityledger_entriesโ financial ledgershipmentsโ Biteship shipment trackingdispute_messagesโ dispute communication threadfee_waiversโ exceptional fee waiversreservationsโ atomic listing reservation (prevents double-purchase race)
Infrastructure tables (16)โ
System-level concerns:
email_templatesโ Resend email templates. Tech debt: data lives in TypedKV (email-tpl:{id}), table is empty in production. PS-444.ab_testsโ A/B test configuration. Tech debt: data lives in TypedKV (ab-test:{id}), table is empty. PS-444.webhook_eventsโ incoming webhook log (Xendit, Biteship). Tech debt: data lives in TypedKV (webhook-event:{id}), table is empty. PS-444.email_indexโ Dormant. Never written or read by app code. Suspected email dedup outbox never wired. PS-438.live_streamsโ Live shopping stream records.id TEXT PK(LS + 12 hex), seller FK,status(live/upcoming/ended),pinned_listing_ids TEXT[], viewer counts. Created PS-447 (2026-05-15) โ was in batch but never applied to production.live_chat_messagesโ Chat messages in a live stream.stream_id FK โ live_streams,user_id TEXT(allows"system"sender),type(message/offer/system). Created PS-447.hiring_jobsโ SEKEN job postings. 32 columns, bilingual_id/_enfields,TEXT[]arrays for requirements/responsibilities/perks,is_active BOOLEAN. Created PS-446 (2026-05-15).hiring_applicationsโ Job applications.job_id FK โ hiring_jobs,status(new/reviewed/shortlisted/rejected/hired),attachments JSONB. Created PS-445 (2026-05-15).push_subscriptionsโ device push tokenslisting_likesโ buyer wishlists. One row per (user, listing).bump_listing_like_counttrigger maintainslistings.likesatomically. Always uselisting_likesโ neverfavorites(stale name, fixed PS-434).referralsโ seller referral tracking. Indexes on(referrer_id)and(buyer_id)added PS-439. Note: referred-user column isbuyer_idin schema.audit_logโ application-wide audit trailbrand_auth_requestsโ luxury brand authenticationduplicate_groupsโ duplicate listing detectionauth_verification_tokensโ email verification + password resetoutbox_eventsโ transactional outbox pattern. Drained by the in-processOutboxScheduler(@nestjs/schedule,@Cronevery 15 min) in the NestJS backend โ no longer an external cron.moderation_override_logโ ops moderation actions auditshipping_rates_cacheโ Biteship rate cacheidempotency_keysโ duplicate request preventionwebhook_logโ durable DB-backed webhook audit log replacing the stalewebhook_eventsKV. Columns:source,event_type,payload JSONB,status,error_message,idempotency_key,received_at,processed_at. All four production webhook handlers instrument this table fail-open.
RLS patternโ
Every table carries a documented access model. Since the NestJS + Drizzle migration, access control is enforced at the application layer โ the marketplace's own SEKEN JWT plus the capability matrix. RLS policies still ship in the migrations as defense-in-depth.
| Pattern | Used by |
|---|---|
| Public read, owner write | listings, reviews, user_profiles (limited fields) |
| Owner only | addresses, bank_accounts, chat_messages, notifications |
| Backend-only | All fin_*, audit_log, moderation_override_log, all webhook tables |
| Role-based | support_tickets (admin sees all, user sees own) |
Key principle: Clients never mutate data directly. All writes go through NestJS controllers, which validate and apply capability checks before Drizzle persists the change.
Soft delete patternโ
Tables that support soft delete have deleted_at (timestamptz, nullable):
listings,chat_messages,reviews,addresses,bank_accounts
Hard delete is reserved for:
- Failed verification artifacts
- Expired ephemeral tokens
- GDPR/PDP requests
Optimistic lockingโ
Tables that use version column for concurrent update protection:
orders(state machine transitions)listings(concurrent edits)fin_seller_balances(concurrent escrow operations)
Update pattern:
UPDATE orders SET status = $1, version = version + 1
WHERE id = $2 AND version = $3
Migration historyโ
| Date | Migration | Notes |
|---|---|---|
| 2026-04-15 | KV โ tables migration | 53 KV patterns โ 60 tables |
| 2026-04-23 | Order fee column rename | bpf โ buyer_protection_fee etc. |
| 2026-04-24 | Order state machine | Added 4 states, DB CHECK constraint |
| 2026-06-08 | 20260608000002_ps943_review_type.sql | PS-943 โ adds review_type TEXT NOT NULL DEFAULT 'listing' to reviews |
| 2026-06-08 | 20260608000003_ps964_voucher_engine.sql | PS-964 โ adds voucher columns to orders; adds analytics columns to vouchers |
| 2026-06-08 | 20260608000004_ps958_webhook_log.sql | PS-958 โ creates webhook_log table |
Open questionsโ
| Question | Owner |
|---|---|
Should audit_log be split per domain area? | Engineer |
Resolved 2026-04-28: finances schema migration โ finance tables stay in public with fin_ prefix; revisit at cuan separation.
Where to look in the codebaseโ
| What you want | Where it lives |
|---|---|
| Migration files | supabase/migrations/ โ applied via deploy-time replay onto external Postgres |
| Schema TypeScript types | backend/src/infrastructure/persistence/row-types.ts |
| Repository implementations | backend/src/infrastructure/repositories/ (Drizzle over postgres.js) |
| RLS policy SQL | supabase/migrations/ (defense-in-depth; application layer is the live gate) |
Next: Ops Portal โ