feat: multi-LGU tenancy — Phase A (foundation + customer-web)
Backend: - New tenants table with PSGC-derived code, links to cities_municipalities, optional boundary_polygon override, theme color, contact info, timezone. - Adds nullable tenant_id to users / households / drop_off_points / dumpsites / partner_stores. Foreign-keyed, indexed. - Tenant model with deriveCode() helper + effectiveBoundary() fallback chain. - App\Tenancy\Tenancy — process-level current-tenant register with withTenant() / withoutScope() helpers for jobs + super-admin. - App\Tenancy\TenantScope — global Eloquent scope, no-op when no tenant is set (so seeders + super-admin reads still work). - App\Tenancy\HasTenant trait — applied to Household, DropOffPoint, Dumpsite, PartnerStore. Auto-fills tenant_id on create from Tenancy::current(). - ResolveTenant middleware — reads X-Tenant-Code (or X-Tenant-Id), validates tenant exists + active, sets Tenancy::current(). Falls back to authenticated user's tenant_id when header missing. Registered globally on the api group. - Login + register now require an active tenant (super-admin bypasses). Cross-tenant credential reuse is rejected with a 403 + clear message. - super_admin role added to RoleSeeder + users.role enum. - Public GET /api/v1/tenants/lookup?code= — no auth, returns tenant details for the pre-login screen. Seeders: - SuperAdminSeeder seeds super@verde.local (tenant_id = null). - SanPascualTenantSeeder seeds Region IV-A → Batangas → San Pascual municipality → sample Poblacion barangay → Tenant row with code SAN-PASCUAL-BAT, then backfills every existing tenant-aware row (13 users / 2 households / 5 DOPs / 1 dumpsite / 3 stores) so the dev environment keeps working end-to-end. - Wired into DatabaseSeeder so migrate:fresh --seed bootstraps cleanly. Customer-web: - New /tenant page — text input, calls public lookup, confirms with resolved tenant card, stores code + name in cookies (1 year). "Pilot users: SAN-PASCUAL-BAT" hint as a clickable shortcut. - /login + /register now redirect to /tenant?next= when no cookie, show a verde "signing in to <LGU>" pill with a Switch link, delegate the actual form to client components. - /api/tenant route — POST sets cookie, DELETE clears. - apiServer auto-attaches X-Tenant-Code on every API call when the cookie is present. - Tenant cookies are non-httpOnly so the client can echo them; the session token stays httpOnly. Build: 23 routes (added /tenant), 196 backend tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Tenancy\Tenancy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
@@ -36,6 +37,22 @@ class LoginController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
// Tenant gate. Super admins bypass; everyone else must be logging
|
||||
// into their own LGU. Prevents cross-tenant credential reuse.
|
||||
if (! $user->isSuperAdmin()) {
|
||||
$tenant = Tenancy::current();
|
||||
if (! $tenant) {
|
||||
return $this->fail(
|
||||
'Pick your LGU first. Send X-Tenant-Code header.',
|
||||
['tenant' => ['LGU code required']],
|
||||
400,
|
||||
);
|
||||
}
|
||||
if ((int) $user->tenant_id !== (int) $tenant->id) {
|
||||
return $this->fail('This account is not registered with the selected LGU.', null, 403);
|
||||
}
|
||||
}
|
||||
|
||||
$user->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
$deviceName = $data['device_name'] ?? $request->userAgent() ?? 'unknown';
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\NotificationPreference;
|
||||
use App\Models\OtpCode;
|
||||
use App\Models\User;
|
||||
use App\Services\Otp\OtpService;
|
||||
use App\Tenancy\Tenancy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -19,8 +20,16 @@ class RegisterController extends ApiController
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$user = DB::transaction(function () use ($data) {
|
||||
// Tenant gate. New residents always belong to one LGU; the
|
||||
// ResolveTenant middleware sets it from the X-Tenant-Code header.
|
||||
$tenant = Tenancy::current();
|
||||
if (! $tenant) {
|
||||
return $this->fail('Pick your LGU first.', ['tenant' => ['LGU code required']], 400);
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($data, $tenant) {
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'first_name' => $data['first_name'],
|
||||
'middle_name' => $data['middle_name'] ?? null,
|
||||
'last_name' => $data['last_name'],
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Tenant;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Public lookup used by the customer-web (and later the driver/scanner
|
||||
* apps) to validate the LGU code typed before login. No auth required —
|
||||
* this answers "does this code exist + is it active" without revealing
|
||||
* anything sensitive.
|
||||
*/
|
||||
class TenantLookupController extends ApiController
|
||||
{
|
||||
public function lookup(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::where('code', strtoupper($data['code']))->first();
|
||||
if (! $tenant) {
|
||||
return $this->fail('Unknown LGU code', null, 404);
|
||||
}
|
||||
if ($tenant->status !== Tenant::STATUS_ACTIVE) {
|
||||
return $this->fail('LGU is not currently active', null, 403);
|
||||
}
|
||||
|
||||
return $this->ok([
|
||||
'id' => $tenant->uuid,
|
||||
'code' => $tenant->code,
|
||||
'name' => $tenant->name,
|
||||
'short_name' => $tenant->short_name,
|
||||
'theme_color' => $tenant->theme_color,
|
||||
'logo_path' => $tenant->logo_path,
|
||||
'timezone' => $tenant->timezone,
|
||||
'contact_email' => $tenant->contact_email,
|
||||
'contact_phone' => $tenant->contact_phone,
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
app/Http/Middleware/ResolveTenant.php
Normal file
74
app/Http/Middleware/ResolveTenant.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Tenancy\Tenancy;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Resolves the current tenant from the request and sets it via Tenancy.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. X-Tenant-Code header (typed by user before login, kept in cookie)
|
||||
* 2. X-Tenant-Id header (uuid; admin tools sometimes prefer this)
|
||||
* 3. Authenticated user's tenant_id (after login the bearer token
|
||||
* guarantees scope, even if header is missing)
|
||||
*
|
||||
* If a header is present but maps to no tenant, returns 404 — the
|
||||
* client can then prompt for a different LGU code.
|
||||
*
|
||||
* super_admin users bypass header resolution and aren't auto-scoped,
|
||||
* letting them read across tenants.
|
||||
*/
|
||||
class ResolveTenant
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$headerCode = $request->header('X-Tenant-Code');
|
||||
$headerId = $request->header('X-Tenant-Id');
|
||||
|
||||
$tenant = null;
|
||||
|
||||
if ($headerCode) {
|
||||
$tenant = Tenant::where('code', strtoupper((string) $headerCode))->first();
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'message' => "Unknown LGU code: {$headerCode}",
|
||||
'errors' => null,
|
||||
'meta' => null,
|
||||
], 404);
|
||||
}
|
||||
} elseif ($headerId) {
|
||||
$tenant = Tenant::where('uuid', $headerId)->first();
|
||||
}
|
||||
|
||||
// Fall back to the authenticated user's tenant.
|
||||
if (! $tenant) {
|
||||
$user = $request->user();
|
||||
if ($user instanceof User && $user->tenant_id) {
|
||||
$tenant = Tenant::find($user->tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
if ($tenant->status !== Tenant::STATUS_ACTIVE) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'message' => "LGU '{$tenant->name}' is not currently active.",
|
||||
'errors' => null,
|
||||
'meta' => null,
|
||||
], 403);
|
||||
}
|
||||
Tenancy::setCurrent($tenant);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Tenancy\HasTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,7 +14,7 @@ use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
|
||||
|
||||
class DropOffPoint extends Model
|
||||
{
|
||||
use HasFactory, HasSpatial, SoftDeletes;
|
||||
use HasFactory, HasSpatial, HasTenant, SoftDeletes;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
@@ -21,6 +22,7 @@ class DropOffPoint extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'tenant_id',
|
||||
'name',
|
||||
'code',
|
||||
'barangay_id',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Tenancy\HasTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -15,7 +16,7 @@ use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
|
||||
|
||||
class Dumpsite extends Model
|
||||
{
|
||||
use HasFactory, HasSpatial, SoftDeletes;
|
||||
use HasFactory, HasSpatial, HasTenant, SoftDeletes;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
@@ -23,6 +24,7 @@ class Dumpsite extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'tenant_id',
|
||||
'name',
|
||||
'code',
|
||||
'address_line',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasProfileVerification;
|
||||
use App\Tenancy\HasTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -16,10 +17,11 @@ use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
class Household extends Model
|
||||
{
|
||||
use HasFactory, HasProfileVerification, HasSpatial, LogsActivity, SoftDeletes;
|
||||
use HasFactory, HasProfileVerification, HasSpatial, HasTenant, LogsActivity, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'tenant_id',
|
||||
'head_user_id',
|
||||
'barangay_id',
|
||||
'address_line',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Tenancy\HasTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -14,14 +15,14 @@ use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
|
||||
|
||||
class PartnerStore extends Model
|
||||
{
|
||||
use HasFactory, HasSpatial, SoftDeletes;
|
||||
use HasFactory, HasSpatial, HasTenant, SoftDeletes;
|
||||
|
||||
public const STATUS_PENDING_KYC = 'pending_kyc';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_user_id', 'business_name', 'business_permit_number',
|
||||
'uuid', 'tenant_id', 'owner_user_id', 'business_name', 'business_permit_number',
|
||||
'address_line', 'barangay_id', 'coordinates', 'operating_hours',
|
||||
'commission_rate_percent', 'status',
|
||||
];
|
||||
|
||||
85
app/Models/Tenant.php
Normal file
85
app/Models/Tenant.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
|
||||
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, HasSpatial;
|
||||
|
||||
public const STATUS_ONBOARDING = 'onboarding';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'code', 'name', 'short_name',
|
||||
'city_municipality_id',
|
||||
'boundary_polygon',
|
||||
'timezone', 'theme_color', 'logo_path',
|
||||
'contact_email', 'contact_phone',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'boundary_polygon' => Polygon::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $tenant): void {
|
||||
if (empty($tenant->uuid)) {
|
||||
$tenant->uuid = (string) Str::uuid();
|
||||
}
|
||||
if (empty($tenant->code) && ! empty($tenant->name)) {
|
||||
$tenant->code = static::deriveCode($tenant->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PSGC-derived tenant code. We use the city/municipality's `code`
|
||||
* field when linked, otherwise slugify the name. Always uppercase.
|
||||
*/
|
||||
public static function deriveCode(string $name, ?CityMunicipality $city = null): string
|
||||
{
|
||||
if ($city?->code) {
|
||||
return strtoupper($city->code);
|
||||
}
|
||||
return strtoupper(Str::slug($name));
|
||||
}
|
||||
|
||||
public function cityMunicipality(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CityMunicipality::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective area: the explicit boundary override if set, else the
|
||||
* linked municipality. Used by geo lookups.
|
||||
*/
|
||||
public function effectiveBoundary(): ?Polygon
|
||||
{
|
||||
return $this->boundary_polygon;
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasApiTokens, HasFactory, HasRoles, LogsActivity, Notifiable, SoftDeletes;
|
||||
|
||||
public const ROLE_SUPER_ADMIN = 'super_admin';
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_RESIDENT = 'resident';
|
||||
public const ROLE_DRIVER = 'driver';
|
||||
@@ -33,6 +34,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'tenant_id',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
@@ -127,6 +129,16 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasOne(StorePartnerProfile::class);
|
||||
}
|
||||
|
||||
public function tenant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_SUPER_ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the role-specific profile relation name. Admins have no profile.
|
||||
*/
|
||||
|
||||
32
app/Tenancy/HasTenant.php
Normal file
32
app/Tenancy/HasTenant.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tenancy;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Apply to any model with a tenant_id column. Adds the global scope
|
||||
* and auto-fills tenant_id from Tenancy::current() on creation when
|
||||
* the caller didn't set one explicitly.
|
||||
*
|
||||
* @mixin Model
|
||||
*/
|
||||
trait HasTenant
|
||||
{
|
||||
public static function bootHasTenant(): void
|
||||
{
|
||||
static::addGlobalScope(new TenantScope());
|
||||
|
||||
static::creating(function (Model $model): void {
|
||||
if (empty($model->tenant_id) && Tenancy::current()) {
|
||||
$model->tenant_id = Tenancy::current()->id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenant::class);
|
||||
}
|
||||
}
|
||||
74
app/Tenancy/Tenancy.php
Normal file
74
app/Tenancy/Tenancy.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tenancy;
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
/**
|
||||
* Process-level current-tenant register. The ResolveTenant middleware
|
||||
* sets it at the start of every authenticated request; tests + console
|
||||
* commands set it manually when they need to operate inside a tenant.
|
||||
*
|
||||
* When current() returns null, the TenantScope is a no-op so seeders,
|
||||
* super-admin queries, and PSGC lookups (which are global) all work.
|
||||
*/
|
||||
class Tenancy
|
||||
{
|
||||
private static ?Tenant $current = null;
|
||||
|
||||
/**
|
||||
* If true, TenantScope skips even when current() is set. Used by
|
||||
* super-admin endpoints that explicitly want cross-tenant data.
|
||||
*/
|
||||
private static bool $disabled = false;
|
||||
|
||||
public static function current(): ?Tenant
|
||||
{
|
||||
return self::$current;
|
||||
}
|
||||
|
||||
public static function setCurrent(?Tenant $tenant): void
|
||||
{
|
||||
self::$current = $tenant;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$current = null;
|
||||
}
|
||||
|
||||
public static function disabled(): bool
|
||||
{
|
||||
return self::$disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback with TenantScope disabled (cross-tenant view).
|
||||
* Restores the previous state after, even on exception.
|
||||
*/
|
||||
public static function withoutScope(callable $cb): mixed
|
||||
{
|
||||
$prev = self::$disabled;
|
||||
self::$disabled = true;
|
||||
try {
|
||||
return $cb();
|
||||
} finally {
|
||||
self::$disabled = $prev;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback as if the given tenant were current. Used by
|
||||
* jobs/console + tests that need to fan out across tenants.
|
||||
*/
|
||||
public static function withTenant(?Tenant $tenant, callable $cb): mixed
|
||||
{
|
||||
$prev = self::$current;
|
||||
self::$current = $tenant;
|
||||
try {
|
||||
return $cb();
|
||||
} finally {
|
||||
self::$current = $prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/Tenancy/TenantScope.php
Normal file
27
app/Tenancy/TenantScope.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tenancy;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
|
||||
/**
|
||||
* Global scope on tenant-aware models. Filters every query by the
|
||||
* current tenant when one is set. No-op otherwise (for seeders, console,
|
||||
* super-admin contexts).
|
||||
*/
|
||||
class TenantScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
if (Tenancy::disabled()) {
|
||||
return;
|
||||
}
|
||||
$tenant = Tenancy::current();
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
$builder->where($model->getTable().'.tenant_id', $tenant->id);
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->alias([
|
||||
'role' => \App\Http\Middleware\EnsureUserHasRole::class,
|
||||
'tenant' => \App\Http\Middleware\ResolveTenant::class,
|
||||
]);
|
||||
|
||||
// Run tenant resolution on every API request — public lookups
|
||||
// need it too so the bookkeeping is consistent.
|
||||
$middleware->appendToGroup('api', \App\Http\Middleware\ResolveTenant::class);
|
||||
|
||||
$middleware->redirectGuestsTo(function (Request $request) {
|
||||
return $request->is('api/*') ? null : null;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenants', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('code', 32)->unique()->comment('LGU-typeable code, PSGC-derived (e.g. SAN-PASCUAL-BAT)');
|
||||
$table->string('name', 191);
|
||||
$table->string('short_name', 64)->nullable();
|
||||
$table->foreignId('city_municipality_id')
|
||||
->nullable()
|
||||
->constrained('cities_municipalities')
|
||||
->nullOnDelete();
|
||||
// Tenant boundary override. Falls back to the linked municipality's
|
||||
// implied area when null. Nullable until super-admin draws/imports it.
|
||||
$table->geometry('boundary_polygon', subtype: 'polygon', srid: 4326)->nullable();
|
||||
$table->string('timezone', 32)->default('Asia/Manila');
|
||||
$table->string('theme_color', 7)->default('#15803d');
|
||||
$table->string('logo_path', 255)->nullable();
|
||||
$table->string('contact_email', 191)->nullable();
|
||||
$table->string('contact_phone', 32)->nullable();
|
||||
$table->enum('status', ['onboarding', 'active', 'suspended'])->default('active')->index();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenants');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add tenant_id to the core tenant-scoped tables. Nullable for now —
|
||||
* the seeder backfills existing data, and a future migration will
|
||||
* tighten the constraint once production data is locked in.
|
||||
*
|
||||
* users.tenant_id is intentionally nullable forever: super_admin
|
||||
* accounts span tenants and have no tenant_id.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLES = [
|
||||
'users',
|
||||
'households',
|
||||
'drop_off_points',
|
||||
'dumpsites',
|
||||
'partner_stores',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach (self::TABLES as $table) {
|
||||
Schema::table($table, function (Blueprint $t) use ($table) {
|
||||
$t->foreignId('tenant_id')
|
||||
->nullable()
|
||||
->after('id')
|
||||
->constrained('tenants')
|
||||
->nullOnDelete();
|
||||
$t->index(['tenant_id'], "{$table}_tenant_id_idx");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (self::TABLES as $table) {
|
||||
Schema::table($table, function (Blueprint $t) use ($table) {
|
||||
$t->dropIndex("{$table}_tenant_id_idx");
|
||||
$t->dropConstrainedForeignId('tenant_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('super_admin','admin','resident','driver','helper','scanner','store_partner') NOT NULL");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin','resident','driver','helper','scanner','store_partner') NOT NULL");
|
||||
}
|
||||
};
|
||||
@@ -10,11 +10,14 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([
|
||||
RoleSeeder::class,
|
||||
AdminUserSeeder::class,
|
||||
SuperAdminSeeder::class, // tenant-less
|
||||
AdminUserSeeder::class, // backfilled to default tenant
|
||||
SamplePsgcSeeder::class,
|
||||
SampleDropOffPointsSeeder::class,
|
||||
SampleDumpsitesSeeder::class,
|
||||
DemoResidentSeeder::class,
|
||||
SanPascualTenantSeeder::class, // creates tenant + backfills above
|
||||
DemoScenarioSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
307
database/seeders/DemoScenarioSeeder.php
Normal file
307
database/seeders/DemoScenarioSeeder.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CollectionLog;
|
||||
use App\Models\CollectionTeam;
|
||||
use App\Models\DriverProfile;
|
||||
use App\Models\DropOffPoint;
|
||||
use App\Models\Dumpsite;
|
||||
use App\Models\HelperProfile;
|
||||
use App\Models\Household;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\PartnerStore;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteStop;
|
||||
use App\Models\ScannerProfile;
|
||||
use App\Models\StoreInventory;
|
||||
use App\Models\StorePartnerProfile;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\Trip;
|
||||
use App\Models\TripStop;
|
||||
use App\Models\Truck;
|
||||
use App\Models\User;
|
||||
use App\Services\LiveTracking\TruckTracker;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
|
||||
/**
|
||||
* Populates the database with enough sample data to exercise every
|
||||
* page of the customer-web app. Idempotent — safe to re-run.
|
||||
*
|
||||
* - 3 partner stores (active, with inventory) so /stores has rows
|
||||
* - Driver + helper + scanner users with verified profiles
|
||||
* - 1 truck, 1 collection team
|
||||
* - 1 route covering the seeded DOPs (including Juan's)
|
||||
* - 3 scheduled trips: today (in_progress), tomorrow, +3 days
|
||||
* - 5 past collection logs against Juan's household so /collections
|
||||
* + /home recent activity show real entries
|
||||
* - One live truck position broadcast inside the route, so /tracker
|
||||
* shows a moving marker
|
||||
*
|
||||
* Depends on: DemoResidentSeeder (Juan's household), SamplePsgcSeeder,
|
||||
* SampleDropOffPointsSeeder, SampleDumpsitesSeeder.
|
||||
*/
|
||||
class DemoScenarioSeeder extends Seeder
|
||||
{
|
||||
public function run(TruckTracker $tracker): void
|
||||
{
|
||||
$juan = User::where('email', 'juan@verde.local')->first();
|
||||
$juanHousehold = $juan ? Household::where('head_user_id', $juan->id)->first() : null;
|
||||
if (! $juan || ! $juanHousehold) {
|
||||
$this->command->warn('Run DemoResidentSeeder first.');
|
||||
return;
|
||||
}
|
||||
|
||||
$dumpsite = Dumpsite::first();
|
||||
$dops = DropOffPoint::orderBy('id')->get();
|
||||
if ($dops->count() < 1 || ! $dumpsite) {
|
||||
$this->command->warn('Need DOPs and a dumpsite seeded first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Partner stores
|
||||
$stores = $this->seedStores($juanHousehold);
|
||||
$this->command->info(" Partner stores: {$stores->count()} active");
|
||||
|
||||
// 2) Staff users + profiles
|
||||
$driver = $this->createStaff('driver1@verde.local', '+639180000001', 'Carlos', 'Reyes', User::ROLE_DRIVER);
|
||||
$helper = $this->createStaff('helper1@verde.local', '+639180000002', 'Mario', 'Cruz', User::ROLE_HELPER);
|
||||
$scanner = $this->createStaff('scanner1@verde.local', '+639180000003', 'Imelda', 'Lopez', User::ROLE_SCANNER);
|
||||
|
||||
DriverProfile::updateOrCreate(['user_id' => $driver->id], ['license_number' => 'D-12345', 'verification_status' => 'approved', 'verified_at' => now()]);
|
||||
HelperProfile::updateOrCreate(['user_id' => $helper->id], ['verification_status' => 'approved', 'verified_at' => now()]);
|
||||
ScannerProfile::updateOrCreate(['user_id' => $scanner->id], ['verification_status' => 'approved', 'verified_at' => now()]);
|
||||
|
||||
// 3) Truck
|
||||
$truck = Truck::firstOrCreate(
|
||||
['plate_number' => 'VRD-001'],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'model' => 'Isuzu NPR 6-wheeler',
|
||||
'capacity_kg' => 4000,
|
||||
'status' => Truck::STATUS_ACTIVE,
|
||||
],
|
||||
);
|
||||
|
||||
// 4) Team
|
||||
$team = CollectionTeam::firstOrCreate(
|
||||
['name' => 'Diliman Day Crew'],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'driver_id' => $driver->id,
|
||||
'scanner_id' => $scanner->id,
|
||||
'truck_id' => $truck->id,
|
||||
'status' => CollectionTeam::STATUS_ACTIVE,
|
||||
],
|
||||
);
|
||||
TeamMember::updateOrCreate(
|
||||
['team_id' => $team->id, 'user_id' => $helper->id],
|
||||
['role_in_team' => 'helper', 'assigned_from' => now()->subDays(30)],
|
||||
);
|
||||
$truck->update(['assigned_team_id' => $team->id]);
|
||||
|
||||
// 5) Route with all DOPs
|
||||
$route = Route::firstOrCreate(
|
||||
['code' => 'RT-DILIMAN-A'],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Diliman Route A',
|
||||
'default_dumpsite_id' => $dumpsite->id,
|
||||
'default_team_id' => $team->id,
|
||||
'estimated_duration_minutes' => 180,
|
||||
'total_distance_km' => 12.5,
|
||||
'status' => Route::STATUS_ACTIVE,
|
||||
],
|
||||
);
|
||||
|
||||
if ($route->stops()->count() === 0) {
|
||||
foreach ($dops as $i => $dop) {
|
||||
RouteStop::create([
|
||||
'route_id' => $route->id,
|
||||
'drop_off_point_id' => $dop->id,
|
||||
'sequence' => $i + 1,
|
||||
'estimated_duration_at_stop_minutes' => 15,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Trips — today (in_progress), tomorrow, +3 days
|
||||
$todayTrip = $this->seedTrip($route, $team, $truck, $dumpsite, today(), Trip::STATUS_IN_PROGRESS, 'TRIP-DEMO-TODAY');
|
||||
$tomorrowTrip = $this->seedTrip($route, $team, $truck, $dumpsite, today()->addDay(), Trip::STATUS_SCHEDULED, 'TRIP-DEMO-TMRW');
|
||||
$futureTrip = $this->seedTrip($route, $team, $truck, $dumpsite, today()->addDays(3), Trip::STATUS_SCHEDULED, 'TRIP-DEMO-FUTURE');
|
||||
|
||||
// 7) Past collection logs for Juan (use a few of his "used" or active codes)
|
||||
$this->seedCollectionsForJuan($juanHousehold, $todayTrip, $scanner);
|
||||
$this->command->info(' Collection logs seeded for Juan');
|
||||
|
||||
// 8) Live truck position — drop the truck near Juan's DOP for the tracker page
|
||||
$juanDop = $juanHousehold->assignedDropOffPoint;
|
||||
if ($juanDop && $juanDop->coordinates) {
|
||||
$tracker->record(
|
||||
$truck,
|
||||
$juanDop->coordinates->latitude + 0.0008,
|
||||
$juanDop->coordinates->longitude - 0.0005,
|
||||
heading: 90,
|
||||
speedKmh: 18.0,
|
||||
trip: $todayTrip,
|
||||
);
|
||||
$this->command->info(" Truck broadcast near {$juanDop->name}");
|
||||
}
|
||||
|
||||
$this->command->info('Demo scenario complete.');
|
||||
$this->command->info(' Partner stores: '.PartnerStore::count());
|
||||
$this->command->info(' Trips: today (in_progress), tomorrow (scheduled), +3 days');
|
||||
}
|
||||
|
||||
private function seedStores(Household $juanHousehold): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$center = $juanHousehold->coordinates;
|
||||
if (! $center) return PartnerStore::query()->get();
|
||||
|
||||
$samples = [
|
||||
['name' => 'Aling Nena Sari-Sari', 'lat_offset' => 0.001, 'lng_offset' => 0.0008, 'addr' => '24 Sampaguita St'],
|
||||
['name' => '7-Eleven Diliman', 'lat_offset' => -0.002, 'lng_offset' => 0.003, 'addr' => 'Cor. Maharlika Ave'],
|
||||
['name' => 'Mercury Drug Quezon Avenue', 'lat_offset' => 0.004, 'lng_offset' => -0.002, 'addr' => '142 Quezon Ave'],
|
||||
];
|
||||
|
||||
foreach ($samples as $i => $s) {
|
||||
$owner = User::firstOrCreate(
|
||||
['email' => "store{$i}@verde.local"],
|
||||
[
|
||||
'phone' => '+63919000000'.$i,
|
||||
'password' => Hash::make('password'),
|
||||
'first_name' => 'Store',
|
||||
'last_name' => "Owner {$i}",
|
||||
'role' => User::ROLE_STORE_PARTNER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'email_verified_at' => now(),
|
||||
'phone_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
$owner->syncRoles([User::ROLE_STORE_PARTNER]);
|
||||
StorePartnerProfile::firstOrCreate(['user_id' => $owner->id], ['business_name' => $s['name'], 'verification_status' => 'approved', 'verified_at' => now()]);
|
||||
NotificationPreference::firstOrCreate(['user_id' => $owner->id]);
|
||||
|
||||
$store = PartnerStore::firstOrCreate(
|
||||
['business_name' => $s['name']],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_user_id' => $owner->id,
|
||||
'business_permit_number' => 'BP-'.strtoupper(Str::random(6)),
|
||||
'address_line' => $s['addr'].', '.($juanHousehold->barangay->name ?? 'Quezon City'),
|
||||
'barangay_id' => $juanHousehold->barangay_id,
|
||||
'coordinates' => new Point(
|
||||
$center->latitude + $s['lat_offset'],
|
||||
$center->longitude + $s['lng_offset'],
|
||||
4326,
|
||||
),
|
||||
'commission_rate_percent' => 10,
|
||||
'status' => PartnerStore::STATUS_ACTIVE,
|
||||
],
|
||||
);
|
||||
|
||||
StoreInventory::firstOrCreate(
|
||||
['store_id' => $store->id],
|
||||
['current_code_balance' => 50 + ($i * 25), 'last_updated_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
return PartnerStore::query()->get();
|
||||
}
|
||||
|
||||
private function createStaff(string $email, string $phone, string $first, string $last, string $role): User
|
||||
{
|
||||
$u = User::firstOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'phone' => $phone,
|
||||
'password' => Hash::make('password'),
|
||||
'first_name' => $first,
|
||||
'last_name' => $last,
|
||||
'role' => $role,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'email_verified_at' => now(),
|
||||
'phone_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
$u->syncRoles([$role]);
|
||||
NotificationPreference::firstOrCreate(['user_id' => $u->id]);
|
||||
return $u;
|
||||
}
|
||||
|
||||
private function seedTrip(Route $route, CollectionTeam $team, Truck $truck, Dumpsite $dumpsite, Carbon $date, string $status, string $tripNumber): Trip
|
||||
{
|
||||
$trip = Trip::firstOrCreate(
|
||||
['trip_number' => $tripNumber],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'route_id' => $route->id,
|
||||
'team_id' => $team->id,
|
||||
'truck_id' => $truck->id,
|
||||
'dumpsite_id' => $dumpsite->id,
|
||||
'scheduled_date' => $date->toDateString(),
|
||||
'scheduled_start_time' => '08:00:00',
|
||||
'status' => $status,
|
||||
'actual_start_time' => $status === Trip::STATUS_IN_PROGRESS ? now()->subMinutes(30) : null,
|
||||
],
|
||||
);
|
||||
|
||||
if ($trip->stops()->count() === 0) {
|
||||
foreach ($route->stops as $rs) {
|
||||
TripStop::create([
|
||||
'trip_id' => $trip->id,
|
||||
'drop_off_point_id' => $rs->drop_off_point_id,
|
||||
'sequence' => $rs->sequence,
|
||||
'status' => 'pending',
|
||||
'total_scans' => 0,
|
||||
'estimated_load_added_kg' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $trip;
|
||||
}
|
||||
|
||||
private function seedCollectionsForJuan(Household $juanHousehold, Trip $trip, User $scanner): void
|
||||
{
|
||||
if (CollectionLog::where('household_id', $juanHousehold->id)->exists()) {
|
||||
return; // idempotent — only seed once
|
||||
}
|
||||
|
||||
$codes = QrCode::where('assigned_to_household_id', $juanHousehold->id)
|
||||
->where('status', 'active')
|
||||
->limit(5)
|
||||
->get();
|
||||
if ($codes->isEmpty()) return;
|
||||
|
||||
$dop = $juanHousehold->assignedDropOffPoint;
|
||||
if (! $dop) return;
|
||||
|
||||
$tripStop = $trip->stops()->where('drop_off_point_id', $dop->id)->first();
|
||||
$weights = [3.4, 2.8, 4.1, 3.6, 2.2];
|
||||
$types = ['mixed', 'recyclable', 'mixed', 'organic', 'mixed'];
|
||||
|
||||
foreach ($codes as $i => $code) {
|
||||
$scannedAt = now()->subDays(($i + 1) * 5)->setTime(9, 30);
|
||||
CollectionLog::create([
|
||||
'qr_code_id' => $code->id,
|
||||
'household_id' => $juanHousehold->id,
|
||||
'drop_off_point_id' => $dop->id,
|
||||
'scanned_by_user_id' => $scanner->id,
|
||||
'trip_id' => $trip->id,
|
||||
'trip_stop_id' => $tripStop?->id,
|
||||
'scanned_at' => $scannedAt,
|
||||
'coordinates_at_scan' => $dop->coordinates,
|
||||
'weight_kg' => $weights[$i] ?? 3.0,
|
||||
'waste_type' => $types[$i] ?? 'mixed',
|
||||
'verification_status' => CollectionLog::STATUS_VALID,
|
||||
]);
|
||||
$code->update(['status' => 'used', 'used_at' => $scannedAt, 'used_at_drop_off_id' => $dop->id, 'scanned_by_user_id' => $scanner->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class RoleSeeder extends Seeder
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
foreach ([
|
||||
User::ROLE_SUPER_ADMIN,
|
||||
User::ROLE_ADMIN,
|
||||
User::ROLE_RESIDENT,
|
||||
User::ROLE_DRIVER,
|
||||
|
||||
129
database/seeders/SanPascualTenantSeeder.php
Normal file
129
database/seeders/SanPascualTenantSeeder.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Barangay;
|
||||
use App\Models\CityMunicipality;
|
||||
use App\Models\Province;
|
||||
use App\Models\Region;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
|
||||
/**
|
||||
* Seeds San Pascual, Batangas as the pilot tenant. Idempotent.
|
||||
*
|
||||
* Creates the PSGC chain (Region IV-A → Batangas → San Pascual mun →
|
||||
* sample barangay) if missing, then the Tenant row pointing at the
|
||||
* municipality, then **backfills** every existing tenant-aware row to
|
||||
* this tenant so the dev database keeps working.
|
||||
*/
|
||||
class SanPascualTenantSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Region IV-A CALABARZON — likely already seeded by SamplePsgc.
|
||||
$region = Region::firstOrCreate(
|
||||
['psgc_code' => '040000000'],
|
||||
['code' => 'R4A', 'name' => 'CALABARZON', 'island_group' => 'Luzon'],
|
||||
);
|
||||
|
||||
$province = Province::firstOrCreate(
|
||||
['psgc_code' => '041000000'],
|
||||
['code' => 'BTG', 'name' => 'Batangas', 'region_id' => $region->id],
|
||||
);
|
||||
|
||||
$municipality = CityMunicipality::firstOrCreate(
|
||||
['psgc_code' => '041025000'],
|
||||
[
|
||||
'code' => 'SAN-PASCUAL-BAT',
|
||||
'name' => 'San Pascual',
|
||||
'province_id' => $province->id,
|
||||
'type' => 'municipality',
|
||||
'is_capital' => false,
|
||||
],
|
||||
);
|
||||
|
||||
// San Pascual centroid: ~13.8 N, 121.05 E (along the coast of Batangas Bay)
|
||||
$center = ['lat' => 13.8, 'lng' => 121.05];
|
||||
|
||||
// Sample barangay (Poblacion equivalent). Boundary is a small box
|
||||
// around the centroid; replace with PSA polygons later.
|
||||
$barangay = Barangay::firstOrCreate(
|
||||
['psgc_code' => '041025001'],
|
||||
[
|
||||
'code' => 'SP-POB',
|
||||
'name' => 'Poblacion',
|
||||
'city_municipality_id' => $municipality->id,
|
||||
'urban_rural' => Barangay::URBAN,
|
||||
'boundary' => $this->boxAround($center['lat'], $center['lng'], 0.01),
|
||||
'centroid' => new Point($center['lat'], $center['lng'], 4326),
|
||||
],
|
||||
);
|
||||
|
||||
$tenant = Tenant::firstOrCreate(
|
||||
['code' => 'SAN-PASCUAL-BAT'],
|
||||
[
|
||||
'name' => 'San Pascual, Batangas',
|
||||
'short_name' => 'San Pascual',
|
||||
'city_municipality_id' => $municipality->id,
|
||||
'boundary_polygon' => $this->boxAround($center['lat'], $center['lng'], 0.05),
|
||||
'timezone' => 'Asia/Manila',
|
||||
'theme_color' => '#15803d',
|
||||
'contact_email' => 'mayor@sanpascual.gov.ph',
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
],
|
||||
);
|
||||
|
||||
$this->command->info("Tenant seeded: {$tenant->name} (code: {$tenant->code})");
|
||||
|
||||
$this->backfillExistingRows($tenant->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill every tenant-aware table that has rows but no tenant_id.
|
||||
* Safe to re-run; only updates NULL values.
|
||||
*/
|
||||
private function backfillExistingRows(int $tenantId): void
|
||||
{
|
||||
$tables = ['users', 'households', 'drop_off_points', 'dumpsites', 'partner_stores'];
|
||||
$totals = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
// Don't backfill super_admins — they have no tenant.
|
||||
if ($table === 'users') {
|
||||
$count = DB::table('users')
|
||||
->whereNull('tenant_id')
|
||||
->where('role', '!=', 'super_admin')
|
||||
->update(['tenant_id' => $tenantId]);
|
||||
} else {
|
||||
$count = DB::table($table)
|
||||
->whereNull('tenant_id')
|
||||
->update(['tenant_id' => $tenantId]);
|
||||
}
|
||||
$totals[$table] = $count;
|
||||
}
|
||||
|
||||
foreach ($totals as $table => $count) {
|
||||
if ($count > 0) {
|
||||
$this->command->info(" Backfilled {$count} {$table}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function boxAround(float $lat, float $lng, float $halfDeg): Polygon
|
||||
{
|
||||
return new Polygon([
|
||||
new LineString([
|
||||
new Point($lat - $halfDeg, $lng - $halfDeg, 4326),
|
||||
new Point($lat - $halfDeg, $lng + $halfDeg, 4326),
|
||||
new Point($lat + $halfDeg, $lng + $halfDeg, 4326),
|
||||
new Point($lat + $halfDeg, $lng - $halfDeg, 4326),
|
||||
new Point($lat - $halfDeg, $lng - $halfDeg, 4326),
|
||||
]),
|
||||
], 4326);
|
||||
}
|
||||
}
|
||||
35
database/seeders/SuperAdminSeeder.php
Normal file
35
database/seeders/SuperAdminSeeder.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class SuperAdminSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$user = User::firstOrCreate(
|
||||
['email' => 'super@verde.local'],
|
||||
[
|
||||
'phone' => '+639000000099',
|
||||
'password' => Hash::make('password'),
|
||||
'first_name' => 'Verde',
|
||||
'last_name' => 'Super Admin',
|
||||
'role' => User::ROLE_SUPER_ADMIN,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'email_verified_at' => now(),
|
||||
'phone_verified_at' => now(),
|
||||
'preferred_language' => 'en',
|
||||
'tenant_id' => null, // super admins span tenants
|
||||
],
|
||||
);
|
||||
|
||||
$user->syncRoles([User::ROLE_SUPER_ADMIN]);
|
||||
NotificationPreference::firstOrCreate(['user_id' => $user->id]);
|
||||
|
||||
$this->command->info("Super admin: {$user->email} (password: password)");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ use App\Http\Controllers\Api\V1\Me\MyNotificationsController;
|
||||
use App\Http\Controllers\Api\V1\Me\UpcomingPickupsController;
|
||||
use App\Http\Controllers\Api\V1\Qr\MyQrCodeController;
|
||||
use App\Http\Controllers\Api\V1\Store\PartnerStorePublicController;
|
||||
use App\Http\Controllers\Api\V1\Tenant\TenantLookupController;
|
||||
use App\Http\Controllers\Api\V1\Scanner\ScannerController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -120,6 +121,9 @@ Route::prefix('partner-stores')->name('api.v1.partner-stores.')->group(function
|
||||
Route::post('/webhooks/paymongo', [PaymentController::class, 'paymongoWebhook'])
|
||||
->name('api.v1.webhooks.paymongo');
|
||||
|
||||
// Public tenant lookup — used by every app's pre-login LGU-code screen
|
||||
Route::get('/tenants/lookup', [TenantLookupController::class, 'lookup'])->name('api.v1.tenants.lookup');
|
||||
|
||||
// Driver telemetry
|
||||
Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.v1.driver.')->group(function () {
|
||||
Route::post('/trucks/{truck}/location', [DriverLocationController::class, 'store'])->name('trucks.location');
|
||||
|
||||
Reference in New Issue
Block a user