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:
2026-05-01 22:38:35 +08:00
parent 0883d63abb
commit fcf2f9d0bf
23 changed files with 976 additions and 7 deletions

View File

@@ -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';

View File

@@ -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'],

View File

@@ -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,
]);
}
}

View 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);
}
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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
View 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';
}
}

View File

@@ -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
View 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
View 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;
}
}
}

View 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);
}
}

View File

@@ -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;
});

View File

@@ -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');
}
};

View File

@@ -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');
});
}
}
};

View File

@@ -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");
}
};

View File

@@ -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,
]);
}
}

View 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]);
}
}
}

View File

@@ -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,

View 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);
}
}

View 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)");
}
}

View File

@@ -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');