D
Engineering

Multi-tenant

Data antar-merchant dipisah lewat store_id. Query tanpa scope store_id adalah kebocoran data antar-merchant.

dazoapp adalah aplikasi multi-tenant. Data antar-merchant dipisah lewat field store_id. **Query tanpa scope = kebocoran data antar-merchant.`

Aturan dasar

php
// BENAR
$bank = Bank::where('store_id', $store->store_id)->get();

// SALAH — bocor ke semua merchant
$bank = Bank::all();

store_id dipakai di 795 tempat di controller — ini adalah scope utama project.

Cara mengambil $store

User punya dua relasi ke store, tapi hanya satu yang aman untuk semua role:

RelasiModel tujuanBerlaku untukCatatan
user->store()Store (hasOne, user_id)Hanya ownerStore menyimpan user_id owner. Akan return null untuk role lain
user->user_store()->store()UserStore → StoreSemua role (owner, CS, dll)Lewat tabel pivot UserStore

Pola dominan (1.038 pemakaian di controller)

php
// AMAN untuk semua role
$user = auth()->user();
$store_id = $user->user_store->store_id;          // ambil langsung dari pivot
// atau
$store = $user->user_store->store;                // model Store lengkap
$store_id = $store->id;

Beberapa controller memuat relasi secara eager untuk jaga-jaga:

php
$user = Auth::user()->loadMissing(['store', 'user_store.store']);
$store_id = $user->user_store->store->id;

Contoh nyata di codebase:

php
// AgentAiController.php:214
->where('store_id', $user->user_store->store->id)

// BroadcastchatController.php:638
->where('store_id', $user->user_store->store_id)

Pola yang umum

Query dasar

php
$store_id = auth()->user()->user_store->store_id;

$orders = Order::where('store_id', $store_id)->get();
$product = Product::where('store_id', $store_id)->findOrFail($id);
$count = Customer::where('store_id', $store_id)->count();

Query dengan filter tambahan

php
$orders = Order::where('store_id', $store_id)
    ->where('status', 'paid')
    ->latest()
    ->paginate(20);

Relasi sudah scoped?

Tidak otomatis. belongsTo dan hasMany tidak mewarisi store_id. Jika relasi ke model tenant- lain, tetap tambahkan scope:

php
// Misal: order punya order_item
$order = Order::where('store_id', $store_id)->findOrFail($orderId);
$items = $order->order_item;  // Aman — lewat relasi dari parent yang sudah scoped

// Tapi jika query langsung:
$items = OrderItem::where('order_id', $orderId)->get();  // BAHAYA — belum ada jaminan scope

Jebakan umum

1. all() dan first() tanpa scope

php
// BAHAYA — ambil semua merchant
$products = Product::all();
$setting = Setting::first();  // bisa ambil setting merchant lain

2. $user->store untuk non-owner

php
// BAHAYA — return null untuk CS/admin/role non-owner
$store = auth()->user()->store;
$store_id = $store->store_id;  // Error: null->store_id

// BENAR — lewat user_store, jalan untuk semua role
$store_id = auth()->user()->user_store->store_id;

Pengecualian: jika kode hanya dijalankan oleh owner (mis. onboarding), $user->store valid. Tapi untuk konsistensi, lebih baik selalu pakai user_store.

3. Query di Job/Observer

Job dan observer tidak punya session user. Pastikan store_id dilewatkan atau diambil dari konteks:

php
// Di Job
class PaymentTimer implements ShouldQueue
{
    public function __construct(public string $orderId, public string $storeId) {}
    
    public function handle()
    {
        $order = Order::where('store_id', $this->storeId)
            ->findOrFail($this->orderId);
    }
}

4. Query di console command

php
class Downgrade extends Command
{
    public function handle()
    {
        // Iterasi semua store, jangan query tanpa scope
        Store::chunk(100, function ($store) {
            $subscriptions = SubscriptionPayment::where('store_id', $store->store_id)
                ->where('status', 'expired')
                ->get();
            // ...
        });
    }
}

5. Subquery yang lupa scope

php
$store_id = auth()->user()->user_store->store_id;

// BAHAYA — subquery tidak otomatis scoped
$orders = Order::where('store_id', $store_id)
    ->whereHas('customer', function ($q) {
        $q->where('status', 'active');  // di mana store_id customer?
    })
    ->get();

// BENAR
$orders = Order::where('store_id', $store_id)
    ->whereHas('customer', function ($q) use ($store_id) {
        $q->where('store_id', $store_id)
          ->where('status', 'active');
    })
    ->get();

Model yang WAJIB scoped

Praktis semua model merchant-facing wajib store_id:

KategoriModel
Order & PaymentOrder, OrderItem, OrderPayment, Wallet, WalletTransaction, Expense
ProdukProduct, ProductVariant, Variation, Stock, ItemProduct, Category, Collection
CustomerCustomer, Tag, Segment
WhatsAppDevice, Agent, ChatList, ChatHistory, Chatbot
BroadcastBroadcastSceduler, BroadcastMessage, BroadcastLog
Digital StoreDigitalStore, DigitalStorePage
MarketingBiolink, CustomDomain, Pixel, Conversion, RegistrationLink
ShippingWarehouse, HandleGudang, Bank, BankPayment
SettingStore, Setting, Package, TemplateMessage, ResponseTemplate
TeamUserStore, VendorUser

Model global (tidak scoped): User, UserRole, UserAspire, Province, City, Subdistrict, Domain, Notification, LoginLog.

Mencegah kebocoran di kode baru

  1. Ambil $store_id dari auth()->user()->user_store->store_id di awal method — bukan ->store
  2. Setiap query model merchant-facing: tambahkan where('store_id', $store_id)
  3. Jangan pakai Model::all() atau Model::first() untuk model tenant
  4. Di Job/Observer/Command, lewatkan store_id eksplisit (tidak ada session user)
  5. Saat review: cari ::all(, ::first(, ::find( tanpa where('store_id' di sekitarnya
  6. Saat review: cari ->store tanpa user_store — berbahaya untuk non-owner

Middleware pengaman

RedirectIfStoreOrWarehouseNotExisted.php memastikan user punya store & warehouse sebelum akses route tertentu. Tapi ini tidak meng-scope query — hanya memverifikasi keberadaan store. Scope tetap tanggung jawab developer.

Langkah berikutnya