name: nazim-multi-tenancy description: Enforces organization and school isolation for the Nazim multi-tenant SaaS. Use when adding tables, hooks, API calls, controllers, or storage paths. Covers organization_id, school_id, query keys, getCurrentSchoolId(), and RLS.
Nazim Multi-Tenancy
The Nazim app is a multi-tenant SaaS. Every tenant table, hook, API call, and controller must enforce organization and school isolation.
Core Rules
- Every tenant table:
organization_id+school_id(for school-scoped) - Frontend hooks:
queryKeyincludesprofile?.organization_idANDprofile?.default_school_id - API calls: pass both
organization_idandschool_id - Backend: use
getCurrentSchoolId($request)— never trust clientschool_id - Storage paths: include
organization_id(andschool_idwhen applicable)
Frontend Patterns
Query Key (REQUIRED)
queryKey: ['resource', profile?.organization_id, profile?.default_school_id ?? null, ...otherKeys]
API Call (REQUIRED)
await apiClient.resource.list({
organization_id: profile.organization_id,
school_id: profile.default_school_id,
});
Hook enabled
enabled: !!user && !!profile && !!profile.organization_id && !!profile.default_school_id
Mutations
- Create: use
profile.organization_idandprofile.default_school_id(ignore client values) - Update: validate
organization_idmatches; rejectorganization_idchanges - Delete: backend validates; frontend invalidates + refetches
Backend Patterns
Controller
$profile = DB::table('profiles')->where('id', $user->id)->first();
if (!$profile || !$profile->organization_id) {
return response()->json(['error' => 'User must be assigned to an organization'], 403);
}
$currentSchoolId = $this->getCurrentSchoolId($request); // From middleware, NOT client
$query = YourModel::whereNull('deleted_at')
->where('organization_id', $profile->organization_id)
->where('school_id', $currentSchoolId);
getCurrentSchoolId()
- Provided by base Controller
- Reads
current_school_idfrom request (injected byschool.contextmiddleware) - Never use client-provided
school_idfor filtering
Database
- Tenant tables:
organization_id UUID NOT NULL,school_id UUID(school-scoped) - Index on
organization_idandschool_id - RLS enabled; policies enforce
organization_id = (SELECT organization_id FROM profiles WHERE id = auth.uid())
Checklist
- Table has
organization_id(andschool_idif school-scoped) - Query key includes
default_school_id - API passes
school_id - Controller uses
getCurrentSchoolId()not clientschool_id - Mutations validate organization scope
Additional Resources
- Full RLS policy pattern and security checklist: reference.md