From fcf2f9d0bf4e99779d65f6d81032bd3f713c7072 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 22:38:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20multi-LGU=20tenancy=20=E2=80=94=20Phase?= =?UTF-8?q?=20A=20(foundation=20+=20customer-web)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) --- .../Api/V1/Auth/LoginController.php | 17 + .../Api/V1/Auth/RegisterController.php | 11 +- .../Api/V1/Tenant/TenantLookupController.php | 44 +++ app/Http/Middleware/ResolveTenant.php | 74 +++++ app/Models/DropOffPoint.php | 4 +- app/Models/Dumpsite.php | 4 +- app/Models/Household.php | 4 +- app/Models/PartnerStore.php | 5 +- app/Models/Tenant.php | 85 +++++ app/Models/User.php | 12 + app/Tenancy/HasTenant.php | 32 ++ app/Tenancy/Tenancy.php | 74 +++++ app/Tenancy/TenantScope.php | 27 ++ bootstrap/app.php | 5 + ...2026_06_01_100000_create_tenants_table.php | 39 +++ ...01_100001_add_tenant_id_to_core_tables.php | 48 +++ ...002_add_super_admin_to_users_role_enum.php | 17 + database/seeders/DatabaseSeeder.php | 5 +- database/seeders/DemoScenarioSeeder.php | 307 ++++++++++++++++++ database/seeders/RoleSeeder.php | 1 + database/seeders/SanPascualTenantSeeder.php | 129 ++++++++ database/seeders/SuperAdminSeeder.php | 35 ++ routes/api.php | 4 + 23 files changed, 976 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Tenant/TenantLookupController.php create mode 100644 app/Http/Middleware/ResolveTenant.php create mode 100644 app/Models/Tenant.php create mode 100644 app/Tenancy/HasTenant.php create mode 100644 app/Tenancy/Tenancy.php create mode 100644 app/Tenancy/TenantScope.php create mode 100644 database/migrations/2026_06_01_100000_create_tenants_table.php create mode 100644 database/migrations/2026_06_01_100001_add_tenant_id_to_core_tables.php create mode 100644 database/migrations/2026_06_01_100002_add_super_admin_to_users_role_enum.php create mode 100644 database/seeders/DemoScenarioSeeder.php create mode 100644 database/seeders/SanPascualTenantSeeder.php create mode 100644 database/seeders/SuperAdminSeeder.php diff --git a/app/Http/Controllers/Api/V1/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Auth/LoginController.php index 83706a3..652026a 100644 --- a/app/Http/Controllers/Api/V1/Auth/LoginController.php +++ b/app/Http/Controllers/Api/V1/Auth/LoginController.php @@ -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'; diff --git a/app/Http/Controllers/Api/V1/Auth/RegisterController.php b/app/Http/Controllers/Api/V1/Auth/RegisterController.php index abbe87d..3477149 100644 --- a/app/Http/Controllers/Api/V1/Auth/RegisterController.php +++ b/app/Http/Controllers/Api/V1/Auth/RegisterController.php @@ -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'], diff --git a/app/Http/Controllers/Api/V1/Tenant/TenantLookupController.php b/app/Http/Controllers/Api/V1/Tenant/TenantLookupController.php new file mode 100644 index 0000000..d6e97e3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Tenant/TenantLookupController.php @@ -0,0 +1,44 @@ +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, + ]); + } +} diff --git a/app/Http/Middleware/ResolveTenant.php b/app/Http/Middleware/ResolveTenant.php new file mode 100644 index 0000000..4ffc2bd --- /dev/null +++ b/app/Http/Middleware/ResolveTenant.php @@ -0,0 +1,74 @@ +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); + } +} diff --git a/app/Models/DropOffPoint.php b/app/Models/DropOffPoint.php index e4f75ec..02f9127 100644 --- a/app/Models/DropOffPoint.php +++ b/app/Models/DropOffPoint.php @@ -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', diff --git a/app/Models/Dumpsite.php b/app/Models/Dumpsite.php index c123f9d..1bcb5c6 100644 --- a/app/Models/Dumpsite.php +++ b/app/Models/Dumpsite.php @@ -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', diff --git a/app/Models/Household.php b/app/Models/Household.php index 1abc469..e9b734a 100644 --- a/app/Models/Household.php +++ b/app/Models/Household.php @@ -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', diff --git a/app/Models/PartnerStore.php b/app/Models/PartnerStore.php index 24a05a6..a203004 100644 --- a/app/Models/PartnerStore.php +++ b/app/Models/PartnerStore.php @@ -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', ]; diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php new file mode 100644 index 0000000..4b3dee6 --- /dev/null +++ b/app/Models/Tenant.php @@ -0,0 +1,85 @@ + 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'; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 800a992..c9e2ba7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. */ diff --git a/app/Tenancy/HasTenant.php b/app/Tenancy/HasTenant.php new file mode 100644 index 0000000..37d17cf --- /dev/null +++ b/app/Tenancy/HasTenant.php @@ -0,0 +1,32 @@ +tenant_id) && Tenancy::current()) { + $model->tenant_id = Tenancy::current()->id; + } + }); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenant::class); + } +} diff --git a/app/Tenancy/Tenancy.php b/app/Tenancy/Tenancy.php new file mode 100644 index 0000000..01b1b1e --- /dev/null +++ b/app/Tenancy/Tenancy.php @@ -0,0 +1,74 @@ +where($model->getTable().'.tenant_id', $tenant->id); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 284db1a..7aec1e5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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; }); diff --git a/database/migrations/2026_06_01_100000_create_tenants_table.php b/database/migrations/2026_06_01_100000_create_tenants_table.php new file mode 100644 index 0000000..b666b1f --- /dev/null +++ b/database/migrations/2026_06_01_100000_create_tenants_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_01_100001_add_tenant_id_to_core_tables.php b/database/migrations/2026_06_01_100001_add_tenant_id_to_core_tables.php new file mode 100644 index 0000000..cb485c2 --- /dev/null +++ b/database/migrations/2026_06_01_100001_add_tenant_id_to_core_tables.php @@ -0,0 +1,48 @@ +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'); + }); + } + } +}; diff --git a/database/migrations/2026_06_01_100002_add_super_admin_to_users_role_enum.php b/database/migrations/2026_06_01_100002_add_super_admin_to_users_role_enum.php new file mode 100644 index 0000000..562ea72 --- /dev/null +++ b/database/migrations/2026_06_01_100002_add_super_admin_to_users_role_enum.php @@ -0,0 +1,17 @@ +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, ]); } } diff --git a/database/seeders/DemoScenarioSeeder.php b/database/seeders/DemoScenarioSeeder.php new file mode 100644 index 0000000..bfa32d1 --- /dev/null +++ b/database/seeders/DemoScenarioSeeder.php @@ -0,0 +1,307 @@ +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]); + } + } +} diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 9e424ea..7ad1d29 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -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, diff --git a/database/seeders/SanPascualTenantSeeder.php b/database/seeders/SanPascualTenantSeeder.php new file mode 100644 index 0000000..073266e --- /dev/null +++ b/database/seeders/SanPascualTenantSeeder.php @@ -0,0 +1,129 @@ + '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); + } +} diff --git a/database/seeders/SuperAdminSeeder.php b/database/seeders/SuperAdminSeeder.php new file mode 100644 index 0000000..dee539e --- /dev/null +++ b/database/seeders/SuperAdminSeeder.php @@ -0,0 +1,35 @@ + '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)"); + } +} diff --git a/routes/api.php b/routes/api.php index ac2b088..508cf89 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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');