added full module support
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# 500 fix notes
|
||||
|
||||
This build removes Flux form inputs from the login/register auth forms because the current supplied Flux/Livewire stack can compile internal Flux input views before `$attributes` exists, causing:
|
||||
|
||||
`Undefined variable $attributes`
|
||||
|
||||
The auth forms keep the mobile-first Tailwind styling and use normal HTML inputs. Run:
|
||||
|
||||
```bash
|
||||
composer dump-autoload
|
||||
php artisan optimize:clear
|
||||
php artisan view:clear
|
||||
php artisan migrate --seed
|
||||
npm run build
|
||||
```
|
||||
|
||||
Default platform admin:
|
||||
|
||||
- Email: mark.gregory@mpba.co.uk
|
||||
- Password: 987400Kmh
|
||||
@@ -0,0 +1,59 @@
|
||||
# WMS modular install
|
||||
|
||||
## Local ServBay / PostgreSQL install
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
php artisan migrate:fresh --seed
|
||||
npm install
|
||||
npm run build
|
||||
php artisan optimize:clear
|
||||
```
|
||||
|
||||
Admin login:
|
||||
|
||||
```text
|
||||
Email: mark.gregory@mpba.co.uk
|
||||
Password: 987400Kmh
|
||||
```
|
||||
|
||||
## Main pages
|
||||
|
||||
```text
|
||||
/dashboard Tenant WMS dashboard
|
||||
/warehouses Tenant warehouse management
|
||||
/warehouse-rules Warehouse validation rules
|
||||
/admin MPBA system administration dashboard
|
||||
/admin/companies Tenant company management
|
||||
/admin/licenses Licence keys
|
||||
/admin/payments Payments and Stripe override records
|
||||
/api/internal/companies HMAC signed internal API endpoint
|
||||
```
|
||||
|
||||
## Production install
|
||||
|
||||
Use PostgreSQL 18, Redis Sentinel, Laravel Octane, Horizon and HAProxy. Keep database and Redis HA native on Ubuntu. Use Docker only for stateless Laravel app/worker containers.
|
||||
|
||||
```bash
|
||||
composer install --no-dev --prefer-dist --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
php artisan migrate --force
|
||||
php artisan db:seed --force
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
php artisan queue:restart
|
||||
php artisan octane:reload
|
||||
```
|
||||
|
||||
## Test command
|
||||
|
||||
```bash
|
||||
composer install
|
||||
php artisan test
|
||||
```
|
||||
|
||||
The archive does not include `vendor/` or `node_modules/`.
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\InternalApi\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\InternalApi\app\Http\Middleware\VerifyInternalApiSignature;
|
||||
|
||||
class InternalApiServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap module services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Route::middleware('api')->prefix('api/internal')->group(__DIR__ . '/../routes/api.php');
|
||||
app('router')->aliasMiddleware('internal.signed', VerifyInternalApiSignature::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\InternalApi\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
|
||||
class CompanySummaryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return tenant summary for authenticated internal callers.
|
||||
*/
|
||||
public function __invoke()
|
||||
{
|
||||
return response()->json([
|
||||
'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\InternalApi\app\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyInternalApiSignature
|
||||
{
|
||||
/**
|
||||
* Verify the HMAC signature used between separated WMS applications.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$secret = (string) config('platform.internal_api_key');
|
||||
$timestamp = (string) $request->header('X-WMS-Timestamp');
|
||||
$signature = (string) $request->header('X-WMS-Signature');
|
||||
|
||||
abort_if($secret === '' || $timestamp === '' || $signature === '', 401);
|
||||
abort_if(abs(now()->timestamp - strtotime($timestamp)) > 300, 401, 'Expired internal API signature.');
|
||||
|
||||
$expected = hash_hmac('sha256', $timestamp . '.' . $request->getContent(), $secret);
|
||||
abort_unless(hash_equals($expected, $signature), 401, 'Invalid internal API signature.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"InternalApi","alias":"internal-api","description":"Authenticated internal API between separated WMS applications.","keywords":["api","hmac","integration"],"priority":2,"providers":["Modules\\InternalApi\\Providers\\InternalApiServiceProvider"],"files":[]}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\InternalApi\app\Http\Controllers\CompanySummaryController;
|
||||
|
||||
Route::middleware('internal.signed')->group(function (): void {
|
||||
Route::get('/companies', CompanySummaryController::class)->name('internal.companies.index');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\InternalApi\tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InternalApiSignatureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_internal_api_rejects_missing_signature(): void
|
||||
{
|
||||
$this->getJson('/api/internal/companies')->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_internal_api_accepts_valid_signature(): void
|
||||
{
|
||||
config(['platform.internal_api_key' => 'testing-secret']);
|
||||
$timestamp = now()->toIso8601String();
|
||||
$body = '';
|
||||
$signature = hash_hmac('sha256', $timestamp . '.' . $body, 'testing-secret');
|
||||
|
||||
$this->withHeaders([
|
||||
'X-WMS-Timestamp' => $timestamp,
|
||||
'X-WMS-Signature' => $signature,
|
||||
])->getJson('/api/internal/companies')->assertOk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Platform\app\Http\Middleware\EnsureLicensedTenant;
|
||||
use Modules\Platform\app\Http\Middleware\EnsurePlatformAdmin;
|
||||
use Modules\Platform\app\Http\Middleware\EnsureTenantAssigned;
|
||||
|
||||
class PlatformServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register module services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../config/platform.php', 'platform');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap module services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'platform');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
|
||||
Route::middleware('web')->group(__DIR__ . '/../routes/web.php');
|
||||
|
||||
app('router')->aliasMiddleware('tenant.assigned', EnsureTenantAssigned::class);
|
||||
app('router')->aliasMiddleware('tenant.licensed', EnsureLicensedTenant::class);
|
||||
app('router')->aliasMiddleware('platform.admin', EnsurePlatformAdmin::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display companies.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('platform::companies.index', [
|
||||
'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->orderBy('name')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a company tenant.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'billing_email' => ['nullable', 'email', 'max:255'],
|
||||
'status' => ['required', 'in:active,suspended'],
|
||||
]);
|
||||
|
||||
Company::create([
|
||||
...$data,
|
||||
'slug' => Str::slug($data['name']),
|
||||
'timezone' => 'Europe/London',
|
||||
]);
|
||||
|
||||
return back()->with('status', 'Company created.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
|
||||
class LicenseKeyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display licence keys.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('platform::licenses.index', [
|
||||
'companies' => Company::orderBy('name')->get(),
|
||||
'licenses' => LicenseKey::with('company')->latest()->paginate(20),
|
||||
'plainKey' => session('plain_license_key'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a new licence key.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'company_id' => ['required', 'exists:companies,id'],
|
||||
'label' => ['required', 'string', 'max:255'],
|
||||
'plan' => ['required', 'in:standard,professional,enterprise'],
|
||||
'max_users' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
]);
|
||||
|
||||
$company = Company::findOrFail($data['company_id']);
|
||||
$plainKey = LicenseKey::issueFor($company, $data['label'], $data['plan'], (int) $data['max_users']);
|
||||
|
||||
return back()->with('plain_license_key', $plainKey)->with('status', 'Licence key issued. Copy it now.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Platform\app\Models\Payment;
|
||||
|
||||
class PaymentController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display company payments.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('platform::payments.index', [
|
||||
'companies' => Company::orderBy('name')->get(),
|
||||
'payments' => Payment::with('company')->latest()->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a manual payment or admin override record.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'company_id' => ['required', 'exists:companies,id'],
|
||||
'amount' => ['required', 'integer', 'min:0'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'status' => ['required', 'in:pending,paid,failed,refunded,override'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
Payment::create([
|
||||
...$data,
|
||||
'paid_at' => in_array($data['status'], ['paid', 'override'], true) ? now() : null,
|
||||
'metadata' => ['source' => 'manual_admin'],
|
||||
]);
|
||||
|
||||
return back()->with('status', 'Payment record created.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Platform\app\Models\Payment;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
|
||||
class PlatformDashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the platform administration dashboard.
|
||||
*/
|
||||
public function __invoke()
|
||||
{
|
||||
return view('platform::dashboard.index', [
|
||||
'companiesCount' => Company::count(),
|
||||
'activeCompaniesCount' => Company::where('status', 'active')->count(),
|
||||
'licensesCount' => LicenseKey::count(),
|
||||
'paymentsTotal' => Payment::where('status', 'paid')->sum('amount'),
|
||||
'warehousesCount' => Warehouse::count(),
|
||||
'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest()->limit(8)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Platform\app\Models\Payment;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Receive a Stripe webhook event.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$payload = $request->all();
|
||||
$eventType = data_get($payload, 'type');
|
||||
|
||||
if ($eventType === 'payment_intent.succeeded') {
|
||||
$intent = data_get($payload, 'data.object', []);
|
||||
$company = Company::where('stripe_customer_id', data_get($intent, 'customer'))->first();
|
||||
|
||||
if ($company) {
|
||||
Payment::updateOrCreate(
|
||||
['stripe_payment_intent_id' => data_get($intent, 'id')],
|
||||
[
|
||||
'company_id' => $company->id,
|
||||
'amount' => (int) data_get($intent, 'amount_received', 0),
|
||||
'currency' => (string) data_get($intent, 'currency', 'gbp'),
|
||||
'status' => 'paid',
|
||||
'description' => 'Stripe payment received',
|
||||
'paid_at' => now(),
|
||||
'metadata' => $intent,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['received' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureLicensedTenant
|
||||
{
|
||||
/**
|
||||
* Ensure the current company has an active licence unless it is the MPBA platform tenant.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$company = $user?->company;
|
||||
|
||||
abort_if(! $company, 403, 'No company is assigned to this account.');
|
||||
|
||||
if ($user->isMpbaPlatformAdmin()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
abort_unless($company->hasActiveLicense(), 402, 'Payment Required');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePlatformAdmin
|
||||
{
|
||||
/**
|
||||
* Ensure the current user is an MPBA platform administrator.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless($request->user()?->isMpbaPlatformAdmin(), 403);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTenantAssigned
|
||||
{
|
||||
/**
|
||||
* Ensure the authenticated user belongs to a company tenant.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_if(! $user || ! $user->company_id, 403, 'No company is assigned to this account.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
|
||||
class Company extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'name', 'slug', 'status', 'timezone', 'settings',
|
||||
'is_system_company', 'stripe_customer_id', 'billing_email',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
'is_system_company' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Company $company): void {
|
||||
$company->uuid ??= (string) Str::uuid();
|
||||
$company->slug ??= Str::slug($company->name);
|
||||
$company->status ??= 'active';
|
||||
$company->timezone ??= 'Europe/London';
|
||||
});
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\User::class);
|
||||
}
|
||||
|
||||
public function licenseKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(LicenseKey::class);
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
|
||||
public function warehouses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Warehouse::class);
|
||||
}
|
||||
|
||||
public function hasActiveLicense(): bool
|
||||
{
|
||||
if ($this->is_system_company) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->licenseKeys()
|
||||
->where('is_active', true)
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('starts_at')->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>=', now());
|
||||
})
|
||||
->whereNull('revoked_at')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LicenseKey extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id', 'key_hash', 'key_prefix', 'label', 'plan', 'max_users',
|
||||
'is_active', 'starts_at', 'expires_at', 'last_used_at', 'revoked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public static function issueFor(Company $company, string $label, string $plan = 'standard', int $maxUsers = 10): string
|
||||
{
|
||||
$plainKey = 'WMS-' . Str::upper(Str::random(32));
|
||||
|
||||
self::create([
|
||||
'company_id' => $company->id,
|
||||
'key_hash' => hash('sha256', $plainKey),
|
||||
'key_prefix' => Str::substr($plainKey, 0, 12),
|
||||
'label' => $label,
|
||||
'plan' => $plan,
|
||||
'max_users' => $maxUsers,
|
||||
'is_active' => true,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
return $plainKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Payment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id', 'stripe_payment_intent_id', 'stripe_invoice_id', 'amount',
|
||||
'currency', 'status', 'description', 'paid_at', 'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'integer',
|
||||
'paid_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'system_company_slug' => env('WMS_SYSTEM_COMPANY_SLUG', 'mpba-limited'),
|
||||
'system_admin_email' => env('WMS_SYSTEM_ADMIN_EMAIL', 'mark.gregory@mpba.co.uk'),
|
||||
'internal_api_key' => env('WMS_INTERNAL_API_KEY', 'local-dev-change-me'),
|
||||
'stripe' => [
|
||||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('companies', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('status')->default('active')->index();
|
||||
$table->string('timezone')->default('Europe/London');
|
||||
$table->boolean('is_system_company')->default(false)->index();
|
||||
$table->string('stripe_customer_id')->nullable()->index();
|
||||
$table->string('billing_email')->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->foreignId('company_id')->nullable()->after('id')->constrained('companies')->nullOnDelete();
|
||||
$table->boolean('is_platform_admin')->default(false)->after('password');
|
||||
$table->boolean('is_company_admin')->default(false)->after('is_platform_admin');
|
||||
$table->string('status')->default('active')->after('is_company_admin')->index();
|
||||
});
|
||||
|
||||
Schema::create('license_keys', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
$table->string('key_hash', 64)->unique();
|
||||
$table->string('key_prefix', 16)->index();
|
||||
$table->string('label');
|
||||
$table->string('plan')->default('standard')->index();
|
||||
$table->unsignedInteger('max_users')->default(10);
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('revoked_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('payments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
$table->string('stripe_payment_intent_id')->nullable()->unique();
|
||||
$table->string('stripe_invoice_id')->nullable()->index();
|
||||
$table->unsignedInteger('amount')->default(0);
|
||||
$table->string('currency', 3)->default('gbp');
|
||||
$table->string('status')->default('pending')->index();
|
||||
$table->string('description')->nullable();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payments');
|
||||
Schema::dropIfExists('license_keys');
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('company_id');
|
||||
$table->dropColumn(['is_platform_admin', 'is_company_admin', 'status']);
|
||||
});
|
||||
Schema::dropIfExists('companies');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\database\seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||
|
||||
class PlatformSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the MPBA system tenant and administrator.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$company = Company::updateOrCreate(
|
||||
['slug' => 'mpba-limited'],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'MPBA Limited',
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
'is_system_company' => true,
|
||||
'billing_email' => 'mark.gregory@mpba.co.uk',
|
||||
]
|
||||
);
|
||||
|
||||
User::updateOrCreate(
|
||||
['email' => 'mark.gregory@mpba.co.uk'],
|
||||
[
|
||||
'company_id' => $company->id,
|
||||
'name' => 'Mark Gregory',
|
||||
'password' => Hash::make('987400Kmh'),
|
||||
'is_platform_admin' => true,
|
||||
'is_company_admin' => true,
|
||||
'status' => 'active',
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$warehouse = Warehouse::updateOrCreate(
|
||||
['company_id' => $company->id, 'code' => 'MPBA-MAIN'],
|
||||
['name' => 'MPBA Main Warehouse', 'status' => 'active', 'timezone' => 'Europe/London']
|
||||
);
|
||||
|
||||
WarehouseValidationRule::updateOrCreate(
|
||||
['warehouse_id' => $warehouse->id, 'rule_key' => 'stock.location.required'],
|
||||
['name' => 'Stock location required', 'severity' => 'error', 'is_active' => true, 'configuration' => ['required' => true]]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Platform","alias":"platform","description":"System administration, tenants, licences, Stripe payments and tenant guards.","keywords":["wms","multi-tenant","licensing"],"priority":0,"providers":["Modules\\Platform\\Providers\\PlatformServiceProvider"],"files":[]}
|
||||
@@ -0,0 +1,21 @@
|
||||
<x-layouts::app :title="__('Companies')">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h1 class="text-2xl font-bold">Companies</h1>
|
||||
<form method="POST" action="{{ route('platform.companies.store') }}" class="mt-5 grid gap-4 md:grid-cols-4">
|
||||
@csrf
|
||||
<input name="name" placeholder="Company name" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<input name="billing_email" placeholder="billing@example.com" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<select name="status" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option value="active">Active</option><option value="suspended">Suspended</option></select>
|
||||
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead><tr class="border-b"><th class="p-4 text-left">Name</th><th class="p-4 text-left">Users</th><th class="p-4 text-left">Warehouses</th><th class="p-4 text-left">Licences</th><th class="p-4 text-left">Status</th></tr></thead>
|
||||
<tbody>@foreach($companies as $company)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4 font-semibold">{{ $company->name }}</td><td class="p-4">{{ $company->users_count }}</td><td class="p-4">{{ $company->warehouses_count }}</td><td class="p-4">{{ $company->license_keys_count }}</td><td class="p-4">{{ $company->is_system_company ? 'System' : $company->status }}</td></tr>@endforeach</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ $companies->links() }}
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,60 @@
|
||||
<x-layouts::app :title="__('Platform Dashboard')">
|
||||
<div class="space-y-6">
|
||||
<section class="overflow-hidden rounded-3xl bg-zinc-950 p-6 text-white shadow-xl sm:p-8">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-300">MPBA WMS</p>
|
||||
<h1 class="mt-3 text-3xl font-bold tracking-tight sm:text-5xl">Platform administration</h1>
|
||||
<p class="mt-3 max-w-2xl text-zinc-300">Manage tenant companies, licences, payments, warehouses and internal API operations from one resilient control plane.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/10 p-5 backdrop-blur">
|
||||
<p class="text-sm text-zinc-300">System status</p>
|
||||
<p class="mt-2 text-2xl font-bold text-emerald-300">Operational</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
@foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Licences', $licensesCount], ['Paid £', number_format($paymentsTotal / 100, 2)]] as [$label, $value])
|
||||
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ $label }}</p>
|
||||
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-white">{{ $value }}</p>
|
||||
</article>
|
||||
@endforeach
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="flex flex-col gap-3 border-b border-zinc-200 p-5 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent companies</h2>
|
||||
<p class="text-sm text-zinc-500">Tenant isolation is enforced by company ownership on every warehouse record.</p>
|
||||
</div>
|
||||
<a href="{{ route('platform.companies.index') }}" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white dark:bg-white dark:text-zinc-900">Manage companies</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
|
||||
<thead class="bg-zinc-50 dark:bg-zinc-950">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left font-semibold">Company</th>
|
||||
<th class="px-5 py-3 text-left font-semibold">Users</th>
|
||||
<th class="px-5 py-3 text-left font-semibold">Warehouses</th>
|
||||
<th class="px-5 py-3 text-left font-semibold">Licences</th>
|
||||
<th class="px-5 py-3 text-left font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
@foreach ($companies as $company)
|
||||
<tr>
|
||||
<td class="px-5 py-4 font-medium">{{ $company->name }}</td>
|
||||
<td class="px-5 py-4">{{ $company->users_count }}</td>
|
||||
<td class="px-5 py-4">{{ $company->warehouses_count }}</td>
|
||||
<td class="px-5 py-4">{{ $company->license_keys_count }}</td>
|
||||
<td class="px-5 py-4">{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-layouts::app :title="__('Licence Keys')">
|
||||
<div class="space-y-6">
|
||||
@if($plainKey)<div class="rounded-2xl bg-emerald-50 p-4 text-emerald-900 ring-1 ring-emerald-200">New key: <code class="font-bold">{{ $plainKey }}</code></div>@endif
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h1 class="text-2xl font-bold">Licence keys</h1>
|
||||
<form method="POST" action="{{ route('platform.licenses.store') }}" class="mt-5 grid gap-4 md:grid-cols-5">
|
||||
@csrf
|
||||
<select name="company_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</select>
|
||||
<input name="label" placeholder="Licence label" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<select name="plan" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option>standard</option><option>professional</option><option>enterprise</option></select>
|
||||
<input name="max_users" type="number" value="10" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Issue</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><table class="min-w-full text-sm"><thead><tr class="border-b"><th class="p-4 text-left">Company</th><th class="p-4 text-left">Label</th><th class="p-4 text-left">Plan</th><th class="p-4 text-left">Prefix</th><th class="p-4 text-left">Expires</th></tr></thead><tbody>@foreach($licenses as $license)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4">{{ $license->company->name }}</td><td class="p-4 font-semibold">{{ $license->label }}</td><td class="p-4">{{ $license->plan }}</td><td class="p-4"><code>{{ $license->key_prefix }}</code></td><td class="p-4">{{ optional($license->expires_at)->toDateString() }}</td></tr>@endforeach</tbody></table></div>
|
||||
{{ $licenses->links() }}
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,7 @@
|
||||
@props(['title' => 'WMS Platform'])
|
||||
|
||||
<div class="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-layouts::app :title="__('Payments')">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h1 class="text-2xl font-bold">Payments and overrides</h1>
|
||||
<form method="POST" action="{{ route('platform.payments.store') }}" class="mt-5 grid gap-4 md:grid-cols-6">
|
||||
@csrf
|
||||
<select name="company_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</select>
|
||||
<input name="amount" type="number" value="0" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<input name="currency" value="gbp" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<select name="status" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option>paid</option><option>pending</option><option>failed</option><option>refunded</option><option>override</option></select>
|
||||
<input name="description" placeholder="Description" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><table class="min-w-full text-sm"><thead><tr class="border-b"><th class="p-4 text-left">Company</th><th class="p-4 text-left">Amount</th><th class="p-4 text-left">Status</th><th class="p-4 text-left">Description</th></tr></thead><tbody>@foreach($payments as $payment)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4">{{ $payment->company->name }}</td><td class="p-4 font-semibold">{{ strtoupper($payment->currency) }} {{ number_format($payment->amount / 100, 2) }}</td><td class="p-4">{{ $payment->status }}</td><td class="p-4">{{ $payment->description }}</td></tr>@endforeach</tbody></table></div>
|
||||
{{ $payments->links() }}
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Platform\app\Http\Controllers\CompanyController;
|
||||
use Modules\Platform\app\Http\Controllers\LicenseKeyController;
|
||||
use Modules\Platform\app\Http\Controllers\PaymentController;
|
||||
use Modules\Platform\app\Http\Controllers\PlatformDashboardController;
|
||||
use Modules\Platform\app\Http\Controllers\StripeWebhookController;
|
||||
|
||||
Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin'])
|
||||
->prefix('admin')
|
||||
->name('platform.')
|
||||
->group(function (): void {
|
||||
Route::get('/', PlatformDashboardController::class)->name('dashboard');
|
||||
Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index');
|
||||
Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store');
|
||||
Route::get('/licenses', [LicenseKeyController::class, 'index'])->name('licenses.index');
|
||||
Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store');
|
||||
Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index');
|
||||
Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store');
|
||||
});
|
||||
|
||||
Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook');
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LicenseKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_license_key_is_hashed_and_not_stored_plain(): void
|
||||
{
|
||||
$company = Company::create(['name' => 'Tenant A', 'slug' => 'tenant-a']);
|
||||
|
||||
$plainKey = LicenseKey::issueFor($company, 'Production licence', 'enterprise', 50);
|
||||
$license = LicenseKey::firstOrFail();
|
||||
|
||||
$this->assertStringStartsWith('WMS-', $plainKey);
|
||||
$this->assertNotSame($plainKey, $license->key_hash);
|
||||
$this->assertSame(hash('sha256', $plainKey), $license->key_hash);
|
||||
$this->assertTrue($company->fresh()->hasActiveLicense());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Platform\tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlatformAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_mpba_platform_admin_can_access_admin_dashboard_without_licence(): void
|
||||
{
|
||||
$company = Company::create([
|
||||
'name' => 'MPBA Limited',
|
||||
'slug' => 'mpba-limited',
|
||||
'is_system_company' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'email' => 'mark.gregory@mpba.co.uk',
|
||||
'is_platform_admin' => true,
|
||||
'is_company_admin' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee('Platform administration');
|
||||
}
|
||||
|
||||
public function test_non_system_company_requires_license(): void
|
||||
{
|
||||
$company = Company::create([
|
||||
'name' => 'Customer Limited',
|
||||
'slug' => 'customer-limited',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/dashboard')
|
||||
->assertStatus(402);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class WarehouseServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap module services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'warehouse');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
Route::middleware('web')->group(__DIR__ . '/../routes/web.php');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display tenant warehouses.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('warehouse::warehouses.index', [
|
||||
'warehouses' => Warehouse::withCount('validationRules')
|
||||
->where('company_id', auth()->user()->company_id)
|
||||
->orderBy('name')
|
||||
->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tenant warehouse.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:50'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'address' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
Warehouse::create([
|
||||
...$data,
|
||||
'company_id' => $request->user()->company_id,
|
||||
'status' => 'active',
|
||||
'timezone' => $request->user()->company->timezone ?? 'Europe/London',
|
||||
]);
|
||||
|
||||
return back()->with('status', 'Warehouse created.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||
|
||||
class WarehouseDashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show tenant warehouse dashboard.
|
||||
*/
|
||||
public function __invoke()
|
||||
{
|
||||
$companyId = auth()->user()->company_id;
|
||||
|
||||
return view('warehouse::warehouses.dashboard', [
|
||||
'warehousesCount' => Warehouse::where('company_id', $companyId)->count(),
|
||||
'rulesCount' => WarehouseValidationRule::whereHas('warehouse', fn ($query) => $query->where('company_id', $companyId))->count(),
|
||||
'warehouses' => Warehouse::withCount('validationRules')->where('company_id', $companyId)->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||
|
||||
class WarehouseValidationRuleController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display warehouse validation rules.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$companyId = auth()->user()->company_id;
|
||||
|
||||
return view('warehouse::rules.index', [
|
||||
'warehouses' => Warehouse::where('company_id', $companyId)->orderBy('name')->get(),
|
||||
'rules' => WarehouseValidationRule::with('warehouse')
|
||||
->whereHas('warehouse', fn ($query) => $query->where('company_id', $companyId))
|
||||
->latest()
|
||||
->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation rule for a warehouse.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$companyId = $request->user()->company_id;
|
||||
|
||||
$data = $request->validate([
|
||||
'warehouse_id' => ['required', 'exists:warehouses,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'rule_key' => ['required', 'string', 'max:100'],
|
||||
'severity' => ['required', 'in:info,warning,error,blocker'],
|
||||
'configuration' => ['nullable', 'json'],
|
||||
]);
|
||||
|
||||
$warehouse = Warehouse::where('company_id', $companyId)->findOrFail($data['warehouse_id']);
|
||||
|
||||
WarehouseValidationRule::create([
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'name' => $data['name'],
|
||||
'rule_key' => $data['rule_key'],
|
||||
'severity' => $data['severity'],
|
||||
'is_active' => true,
|
||||
'configuration' => $data['configuration'] ? json_decode($data['configuration'], true) : null,
|
||||
]);
|
||||
|
||||
return back()->with('status', 'Validation rule created.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id', 'uuid', 'code', 'name', 'status', 'address', 'timezone', 'settings',
|
||||
];
|
||||
|
||||
protected $casts = ['settings' => 'array'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Warehouse $warehouse): void {
|
||||
$warehouse->uuid ??= (string) Str::uuid();
|
||||
$warehouse->status ??= 'active';
|
||||
$warehouse->timezone ??= 'Europe/London';
|
||||
});
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function validationRules(): HasMany
|
||||
{
|
||||
return $this->hasMany(WarehouseValidationRule::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WarehouseValidationRule extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'warehouse_id', 'name', 'rule_key', 'severity', 'is_active', 'configuration',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'configuration' => 'array',
|
||||
];
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('warehouses', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('code');
|
||||
$table->string('name');
|
||||
$table->string('status')->default('active')->index();
|
||||
$table->text('address')->nullable();
|
||||
$table->string('timezone')->default('Europe/London');
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['company_id', 'code']);
|
||||
});
|
||||
|
||||
Schema::create('warehouse_validation_rules', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('rule_key')->index();
|
||||
$table->string('severity')->default('error');
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->json('configuration')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['warehouse_id', 'rule_key']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('warehouse_validation_rules');
|
||||
Schema::dropIfExists('warehouses');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Warehouse","alias":"warehouse","description":"Warehouse management, warehouse-specific validation rules and operational dashboard.","keywords":["wms","warehouse"],"priority":1,"providers":["Modules\\Warehouse\\Providers\\WarehouseServiceProvider"],"files":[]}
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-layouts::app :title="__('Validation Rules')">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h1 class="text-2xl font-bold">Warehouse validation rules</h1>
|
||||
<form method="POST" action="{{ route('warehouse-rules.store') }}" class="mt-5 grid gap-4 md:grid-cols-6">
|
||||
@csrf
|
||||
<select name="warehouse_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($warehouses as $warehouse)<option value="{{ $warehouse->id }}">{{ $warehouse->name }}</option>@endforeach</select>
|
||||
<input name="name" placeholder="Rule name" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<input name="rule_key" placeholder="stock.location.required" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<select name="severity" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option>error</option><option>warning</option><option>blocker</option><option>info</option></select>
|
||||
<input name="configuration" placeholder='{"min": 1}' class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Add rule</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><table class="min-w-full text-sm"><thead><tr class="border-b"><th class="p-4 text-left">Warehouse</th><th class="p-4 text-left">Rule</th><th class="p-4 text-left">Key</th><th class="p-4 text-left">Severity</th></tr></thead><tbody>@foreach($rules as $rule)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4">{{ $rule->warehouse->name }}</td><td class="p-4 font-semibold">{{ $rule->name }}</td><td class="p-4"><code>{{ $rule->rule_key }}</code></td><td class="p-4">{{ $rule->severity }}</td></tr>@endforeach</tbody></table></div>
|
||||
{{ $rules->links() }}
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,27 @@
|
||||
<x-layouts::app :title="__('WMS Dashboard')">
|
||||
<div class="space-y-6">
|
||||
<section class="overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-950 p-6 text-white shadow-xl sm:p-8">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-300">Warehouse management</p>
|
||||
<h1 class="mt-3 text-3xl font-bold tracking-tight sm:text-5xl">{{ auth()->user()->company->name }}</h1>
|
||||
<p class="mt-3 max-w-2xl text-slate-300">High-performance tenant-isolated warehouse operations with per-warehouse validation and licence-aware access.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:min-w-80">
|
||||
<div class="rounded-2xl bg-white/10 p-4 backdrop-blur"><p class="text-sm text-slate-300">Warehouses</p><p class="mt-2 text-3xl font-bold">{{ $warehousesCount }}</p></div>
|
||||
<div class="rounded-2xl bg-white/10 p-4 backdrop-blur"><p class="text-sm text-slate-300">Rules</p><p class="mt-2 text-3xl font-bold">{{ $rulesCount }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse($warehouses as $warehouse)
|
||||
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="flex items-start justify-between gap-4"><div><p class="text-sm font-semibold text-cyan-600">{{ $warehouse->code }}</p><h2 class="mt-1 text-xl font-bold">{{ $warehouse->name }}</h2></div><span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">{{ $warehouse->status }}</span></div>
|
||||
<p class="mt-4 text-sm text-zinc-500">{{ $warehouse->validation_rules_count }} validation rules configured.</p>
|
||||
</article>
|
||||
@empty
|
||||
<article class="rounded-2xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-800 dark:bg-zinc-900 md:col-span-2 xl:col-span-3"><h2 class="text-xl font-bold">Create your first warehouse</h2><p class="mt-2 text-zinc-500">Warehouses are isolated to your company tenant.</p><a href="{{ route('warehouses.index') }}" class="mt-5 inline-flex rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Add warehouse</a></article>
|
||||
@endforelse
|
||||
</section>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-layouts::app :title="__('Warehouses')">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h1 class="text-2xl font-bold">Warehouses</h1>
|
||||
<form method="POST" action="{{ route('warehouses.store') }}" class="mt-5 grid gap-4 md:grid-cols-4">
|
||||
@csrf
|
||||
<input name="code" placeholder="Code" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<input name="name" placeholder="Warehouse name" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
||||
<input name="address" placeholder="Address" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach($warehouses as $warehouse)
|
||||
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"><p class="text-sm text-cyan-600">{{ $warehouse->code }}</p><h2 class="mt-1 text-xl font-bold">{{ $warehouse->name }}</h2><p class="mt-3 text-sm text-zinc-500">{{ $warehouse->validation_rules_count }} rules</p></article>
|
||||
@endforeach
|
||||
</div>
|
||||
{{ $warehouses->links() }}
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Warehouse\app\Http\Controllers\WarehouseController;
|
||||
use Modules\Warehouse\app\Http\Controllers\WarehouseDashboardController;
|
||||
use Modules\Warehouse\app\Http\Controllers\WarehouseValidationRuleController;
|
||||
|
||||
Route::middleware(['auth', 'verified', 'tenant.assigned', 'tenant.licensed'])
|
||||
->group(function (): void {
|
||||
Route::get('/dashboard', WarehouseDashboardController::class)->name('dashboard');
|
||||
Route::get('/warehouses', [WarehouseController::class, 'index'])->name('warehouses.index');
|
||||
Route::post('/warehouses', [WarehouseController::class, 'store'])->name('warehouses.store');
|
||||
Route::get('/warehouse-rules', [WarehouseValidationRuleController::class, 'index'])->name('warehouse-rules.index');
|
||||
Route::post('/warehouse-rules', [WarehouseValidationRuleController::class, 'store'])->name('warehouse-rules.store');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Warehouse\tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WarehouseIsolationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_company_user_only_sees_their_warehouses(): void
|
||||
{
|
||||
$companyA = Company::create(['name' => 'A Ltd', 'slug' => 'a-ltd']);
|
||||
$companyB = Company::create(['name' => 'B Ltd', 'slug' => 'b-ltd']);
|
||||
LicenseKey::issueFor($companyA, 'A licence');
|
||||
|
||||
$user = User::factory()->create(['company_id' => $companyA->id]);
|
||||
Warehouse::create(['company_id' => $companyA->id, 'code' => 'A1', 'name' => 'A Warehouse']);
|
||||
Warehouse::create(['company_id' => $companyB->id, 'code' => 'B1', 'name' => 'B Warehouse']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/warehouses')
|
||||
->assertOk()
|
||||
->assertSee('A Warehouse')
|
||||
->assertDontSee('B Warehouse');
|
||||
}
|
||||
}
|
||||
@@ -5,29 +5,47 @@ namespace App\Actions\Fortify;
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
* Validate and create a newly registered user with an isolated company tenant.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
'company_name' => ['nullable', 'string', 'max:255'],
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
]);
|
||||
return DB::transaction(function () use ($input): User {
|
||||
$companyName = $input['company_name'] ?? ($input['name'] . ' Company');
|
||||
|
||||
$company = Company::create([
|
||||
'name' => $companyName,
|
||||
'slug' => Str::slug($companyName . '-' . Str::random(6)),
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
]);
|
||||
|
||||
return User::create([
|
||||
'company_id' => $company->id,
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
'is_company_admin' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Company extends \Modules\Platform\app\Models\Company
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class LicenseKey extends \Modules\Platform\app\Models\LicenseKey
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Payment extends \Modules\Platform\app\Models\Payment
|
||||
{
|
||||
}
|
||||
+22
-15
@@ -2,47 +2,54 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'email', 'password', 'is_platform_admin', 'is_company_admin', 'status',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_platform_admin' => 'boolean',
|
||||
'is_company_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)
|
||||
->explode(' ')
|
||||
->take(2)
|
||||
->map(fn ($word) => Str::substr($word, 0, 1))
|
||||
->map(fn ($word) => Str::substr((string) $word, 0, 1))
|
||||
->implode('');
|
||||
}
|
||||
|
||||
public function isMpbaPlatformAdmin(): bool
|
||||
{
|
||||
return $this->is_platform_admin && $this->company?->is_system_company === true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Warehouse extends \Modules\Warehouse\app\Models\Warehouse
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class WarehouseValidationRule extends \Modules\Warehouse\app\Models\WarehouseValidationRule
|
||||
{
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"configuring-horizon",
|
||||
"octane-development",
|
||||
"fluxui-development",
|
||||
"livewire-development",
|
||||
"tailwindcss-development"
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
use Modules\InternalApi\Providers\InternalApiServiceProvider;
|
||||
use Modules\Platform\Providers\PlatformServiceProvider;
|
||||
use Modules\Warehouse\Providers\WarehouseServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
PlatformServiceProvider::class,
|
||||
WarehouseServiceProvider::class,
|
||||
InternalApiServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'namespace' => 'Modules',
|
||||
'paths' => [
|
||||
'modules' => base_path('Modules'),
|
||||
'assets' => public_path('modules'),
|
||||
'migration' => base_path('database/migrations'),
|
||||
],
|
||||
'scan' => [
|
||||
'enabled' => true,
|
||||
'paths' => [base_path('Modules')],
|
||||
],
|
||||
'composer' => [
|
||||
'vendor' => 'mpba',
|
||||
'author' => ['name' => 'MPBA Limited', 'email' => 'mark.gregory@mpba.co.uk'],
|
||||
],
|
||||
];
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\Platform\database\seeders\PlatformSeeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
@@ -15,11 +15,6 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$this->call(PlatformSeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# WMS production baseline
|
||||
|
||||
This project is organised as a modular Laravel 13 WMS suite using nwidart-style modules.
|
||||
|
||||
## Modules
|
||||
|
||||
- `Modules/Platform` - companies, MPBA system administration, licence keys, Stripe payment records and tenant/licence middleware.
|
||||
- `Modules/Warehouse` - tenant-owned warehouses and warehouse-specific validation rules.
|
||||
- `Modules/InternalApi` - HMAC-authenticated API for secure communication between separated core/admin applications.
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Use PostgreSQL 18 in production with HAProxy in front of Patroni-managed primary/replica nodes.
|
||||
|
||||
```env
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=6432
|
||||
DB_DATABASE=wms_platform
|
||||
DB_USERNAME=wms_app
|
||||
DB_PASSWORD=change_me
|
||||
```
|
||||
|
||||
## Redis
|
||||
|
||||
Use Redis Sentinel for cache, queues and sessions.
|
||||
|
||||
```env
|
||||
CACHE_STORE=redis
|
||||
SESSION_DRIVER=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
REDIS_CLIENT=phpredis
|
||||
```
|
||||
|
||||
## MPBA system admin
|
||||
|
||||
Seeded account:
|
||||
|
||||
```text
|
||||
Email: mark.gregory@mpba.co.uk
|
||||
Password: 987400Kmh
|
||||
Company: MPBA Limited
|
||||
```
|
||||
|
||||
MPBA Limited is marked as `is_system_company = true`; MPBA platform admins bypass licence checks.
|
||||
|
||||
## Internal API signing
|
||||
|
||||
Internal callers must send:
|
||||
|
||||
```text
|
||||
X-WMS-Timestamp: ISO8601 timestamp
|
||||
X-WMS-Signature: hash_hmac('sha256', timestamp + '.' + body, WMS_INTERNAL_API_KEY)
|
||||
```
|
||||
|
||||
Requests older than 5 minutes are rejected.
|
||||
|
||||
## Deployment sequence
|
||||
|
||||
```bash
|
||||
composer install --no-dev --prefer-dist --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
php artisan migrate --force
|
||||
php artisan db:seed --force
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
php artisan queue:restart
|
||||
php artisan octane:reload
|
||||
```
|
||||
|
||||
Use rolling deployments behind HAProxy for zero downtime.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Platform": true,
|
||||
"Warehouse": true,
|
||||
"InternalApi": true
|
||||
}
|
||||
@@ -10,11 +10,13 @@
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
<directory>Modules/*/tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
<directory>Modules</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
|
||||
@@ -11,22 +11,50 @@
|
||||
</flux:sidebar.header>
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.group :heading="__('Platform')" class="grid">
|
||||
<flux:sidebar.group :heading="__('Operations')" class="grid">
|
||||
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="building-storefront" :href="route('warehouses.index')" :current="request()->routeIs('warehouses.*')" wire:navigate>
|
||||
{{ __('Warehouses') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="shield-check" :href="route('warehouse-rules.index')" :current="request()->routeIs('warehouse-rules.*')" wire:navigate>
|
||||
{{ __('Validation Rules') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.group>
|
||||
|
||||
@if(auth()->user()?->isMpbaPlatformAdmin())
|
||||
<flux:sidebar.group :heading="__('Administration')" class="grid">
|
||||
<flux:sidebar.item icon="squares-2x2" :href="route('platform.dashboard')" :current="request()->routeIs('platform.dashboard')" wire:navigate>
|
||||
{{ __('Admin Dashboard') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="building-office-2" :href="route('platform.companies.index')" :current="request()->routeIs('platform.companies.*')" wire:navigate>
|
||||
{{ __('Companies') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="key" :href="route('platform.licenses.index')" :current="request()->routeIs('platform.licenses.*')" wire:navigate>
|
||||
{{ __('Licences') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="credit-card" :href="route('platform.payments.index')" :current="request()->routeIs('platform.payments.*')" wire:navigate>
|
||||
{{ __('Payments') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.group>
|
||||
@endif
|
||||
</flux:sidebar.nav>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
|
||||
{{ __('Repository') }}
|
||||
<flux:sidebar.item icon="folder-git-2" href="/up">
|
||||
{{ __('Health Check') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
|
||||
{{ __('Documentation') }}
|
||||
<flux:sidebar.item icon="book-open-text" href="/admin">
|
||||
{{ __('Platform Admin') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.nav>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<x-passkey-verify />
|
||||
@@ -10,50 +9,36 @@
|
||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="relative">
|
||||
<flux:input
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
/>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<flux:link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
|
||||
{{ __('Forgot your password?') }}
|
||||
</flux:link>
|
||||
@endif
|
||||
<div class="grid gap-2">
|
||||
<label for="email" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">{{ __('Email address') }}</label>
|
||||
<input id="email" name="email" type="email" value="{{ old('email') }}" required autofocus autocomplete="email" placeholder="email@example.com" class="w-full rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-cyan-400 dark:focus:ring-cyan-900" />
|
||||
@error('email')<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
|
||||
{{ __('Log in') }}
|
||||
</flux:button>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label for="password" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">{{ __('Password') }}</label>
|
||||
@if (Route::has('password.request'))
|
||||
<a class="text-sm font-medium text-cyan-700 underline underline-offset-4 hover:text-cyan-900 dark:text-cyan-300" href="{{ route('password.request') }}" wire:navigate>{{ __('Forgot your password?') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
<input id="password" name="password" type="password" required autocomplete="current-password" placeholder="{{ __('Password') }}" class="w-full rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-cyan-400 dark:focus:ring-cyan-900" />
|
||||
@error('password')<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input name="remember" type="checkbox" value="1" @checked(old('remember')) class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500 dark:border-zinc-700 dark:bg-zinc-950" />
|
||||
<span>{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="w-full rounded-xl bg-zinc-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-cyan-400 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200" data-test="login-button">
|
||||
{{ __('Log in') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 text-sm text-center rtl:space-x-reverse text-zinc-600 dark:text-zinc-400">
|
||||
<div class="space-x-1 text-center text-sm text-zinc-600 rtl:space-x-reverse dark:text-zinc-400">
|
||||
<span>{{ __('Don\'t have an account?') }}</span>
|
||||
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
|
||||
<a href="{{ route('register') }}" wire:navigate class="font-medium text-cyan-700 underline underline-offset-4 hover:text-cyan-900 dark:text-cyan-300">{{ __('Sign up') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -2,68 +2,42 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<!-- Name -->
|
||||
<flux:input
|
||||
name="name"
|
||||
:label="__('Name')"
|
||||
:value="old('name')"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
:placeholder="__('Full name')"
|
||||
/>
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<flux:input
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Password')"
|
||||
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<flux:input
|
||||
name="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Confirm password')"
|
||||
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
|
||||
{{ __('Create account') }}
|
||||
</flux:button>
|
||||
<div class="grid gap-2">
|
||||
<label for="name" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">{{ __('Name') }}</label>
|
||||
<input id="name" name="name" type="text" value="{{ old('name') }}" required autofocus autocomplete="name" placeholder="{{ __('Full name') }}" class="w-full rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-cyan-400 dark:focus:ring-cyan-900" />
|
||||
@error('name')<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="email" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">{{ __('Email address') }}</label>
|
||||
<input id="email" name="email" type="email" value="{{ old('email') }}" required autocomplete="email" placeholder="email@example.com" class="w-full rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-cyan-400 dark:focus:ring-cyan-900" />
|
||||
@error('email')<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="password" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">{{ __('Password') }}</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="new-password" placeholder="{{ __('Password') }}" class="w-full rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-cyan-400 dark:focus:ring-cyan-900" />
|
||||
@error('password')<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="password_confirmation" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">{{ __('Confirm password') }}</label>
|
||||
<input id="password_confirmation" name="password_confirmation" type="password" required autocomplete="new-password" placeholder="{{ __('Confirm password') }}" class="w-full rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-cyan-400 dark:focus:ring-cyan-900" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full rounded-xl bg-zinc-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-cyan-400 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200" data-test="register-user-button">
|
||||
{{ __('Create account') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<div class="space-x-1 text-center text-sm text-zinc-600 rtl:space-x-reverse dark:text-zinc-400">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
|
||||
<a href="{{ route('login') }}" wire:navigate class="font-medium text-cyan-700 underline underline-offset-4 hover:text-cyan-900 dark:text-cyan-300">{{ __('Log in') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -4,8 +4,4 @@ use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'welcome')->name('home');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::view('dashboard', 'dashboard')->name('dashboard');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
Reference in New Issue
Block a user