Repo rebuild

This commit is contained in:
Mark Gregory
2026-09-15 12:33:28 +01:00
commit be28c4feec
5629 changed files with 317074 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
<?php
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\Company\Models\Company;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user with an isolated company tenant.
*
* @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 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,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke()
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Appearance settings')]
class Appearance extends Component
{
//
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Livewire\Component;
class DeleteUserForm extends Component
{
use PasswordValidationRules;
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => $this->currentPasswordRules(),
]);
$user = Auth::user();
if ($user && Schema::hasTable(config('permission.table_names.model_has_roles', 'model_has_roles'))) {
$user->syncRoles([]);
}
tap($user, $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\ProfileValidationRules;
use Flux\Flux;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Profile settings')]
class Profile extends Component
{
use ProfileValidationRules;
public string $name = '';
public string $email = '';
/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
/**
* Update the profile information for the currently authenticated user.
*/
public function updateProfileInformation(): void
{
$user = Auth::user();
$validated = $this->validate($this->profileRules($user->id));
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
Flux::toast(variant: 'success', text: __('Profile updated.'));
}
/**
* Send an email verification notification to the current user.
*/
public function resendVerificationNotification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Flux::toast(text: __('A new verification link has been sent to your email address.'));
}
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use Exception;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Laravel\Passkeys\Actions\DeletePasskey;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Title('Security settings')]
class Security extends Component
{
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
#[Locked]
public bool $canManageTwoFactor;
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
#[Locked]
public bool $canManagePasskeys;
#[Locked]
public array $passkeys = [];
public bool $showDeleteModal = false;
#[Locked]
public ?int $deletingPasskeyId = null;
#[Locked]
public string $deletingPasskeyName = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
if ($this->canManageTwoFactor) {
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
$this->canManagePasskeys = Features::canManagePasskeys();
if ($this->canManagePasskeys) {
$this->loadPasskeys();
}
}
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
Flux::toast(variant: 'success', text: __('Password updated.'));
}
/**
* Load the user's passkeys.
*/
public function loadPasskeys(): void
{
$this->passkeys = Auth::user()->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->toArray();
}
/**
* Show the delete confirmation modal.
*/
public function confirmDelete(int $passkeyId): void
{
$passkey = Auth::user()->passkeys()->findOrFail($passkeyId);
$this->deletingPasskeyId = $passkey->id;
$this->deletingPasskeyName = $passkey->name;
$this->showDeleteModal = true;
}
/**
* Delete the passkey.
*/
public function deletePasskey(DeletePasskey $deletePasskey): void
{
if (! $this->deletingPasskeyId) {
return;
}
$user = Auth::user();
$passkey = $user->passkeys()->findOrFail($this->deletingPasskeyId);
$deletePasskey($user, $passkey);
$this->closeDeleteModal();
$this->loadPasskeys();
}
/**
* Close the delete confirmation modal.
*/
public function closeDeleteModal(): void
{
$this->showDeleteModal = false;
$this->deletingPasskeyId = null;
$this->deletingPasskeyName = '';
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Get the current modal configuration state.
*/
#[Computed]
public function modalConfig(): array
{
if ($this->twoFactorEnabled) {
return [
'title' => __('Two-factor authentication enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify authentication code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable two-factor authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Settings\TwoFactor;
use Exception;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
class RecoveryCodes extends Component
{
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class LicenseKey extends \Modules\Platform\app\Models\LicenseKey {}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class Payment extends \Modules\Platform\app\Models\Payment {}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
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 Modules\Company\Models\Company;
use Modules\Company\Models\CompanyDirectorAssignment;
use Modules\Company\Models\CompanyDivision;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory<UserFactory> */
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'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_platform_admin' => 'boolean',
'is_company_admin' => 'boolean',
];
}
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
public function divisions(): BelongsToMany
{
return $this->belongsToMany(CompanyDivision::class, 'company_division_user')
->withPivot(['company_id', 'role', 'is_director'])
->withTimestamps();
}
public function directorAssignments(): HasMany
{
return $this->hasMany(CompanyDirectorAssignment::class);
}
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->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;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\LazyLoadingViolationException;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureDefaults();
Model::preventLazyLoading(! app()->isProduction());
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
throw new LazyLoadingViolationException(
$model,
$relation,
);
});
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(
fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('livewire.auth.login'));
Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
Fortify::registerView(fn () => view('livewire.auth.register'));
Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');
return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()) . '|' . $request->ip(),
);
});
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
});
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events;
use Stancl\Tenancy\Jobs;
use Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Middleware;
class TenancyServiceProvider extends ServiceProvider
{
// By default, no namespace is used to support the callable array syntax.
public static string $controllerNamespace = '';
public function events()
{
return [
// Tenant events
Events\CreatingTenant::class => [],
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class,
// Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],
Events\SavingTenant::class => [],
Events\TenantSaved::class => [],
Events\UpdatingTenant::class => [],
Events\TenantUpdated::class => [],
Events\DeletingTenant::class => [],
Events\TenantDeleted::class => [
JobPipeline::make([
Jobs\DeleteDatabase::class,
])->send(function (Events\TenantDeleted $event) {
return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],
// Domain events
Events\CreatingDomain::class => [],
Events\DomainCreated::class => [],
Events\SavingDomain::class => [],
Events\DomainSaved::class => [],
Events\UpdatingDomain::class => [],
Events\DomainUpdated::class => [],
Events\DeletingDomain::class => [],
Events\DomainDeleted::class => [],
// Database events
Events\DatabaseCreated::class => [],
Events\DatabaseMigrated::class => [],
Events\DatabaseSeeded::class => [],
Events\DatabaseRolledBack::class => [],
Events\DatabaseDeleted::class => [],
// Tenancy events
Events\InitializingTenancy::class => [],
Events\TenancyInitialized::class => [
Listeners\BootstrapTenancy::class,
],
Events\EndingTenancy::class => [],
Events\TenancyEnded::class => [
Listeners\RevertToCentralContext::class,
],
Events\BootstrappingTenancy::class => [],
Events\TenancyBootstrapped::class => [],
Events\RevertingToCentralContext::class => [],
Events\RevertedToCentralContext::class => [],
// Resource syncing
Events\SyncedResourceSaved::class => [
Listeners\UpdateSyncedResource::class,
],
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
Events\SyncedResourceChangedInForeignDatabase::class => [],
];
}
public function register()
{
//
}
public function boot()
{
$this->bootEvents();
$this->mapRoutes();
$this->makeTenancyMiddlewareHighestPriority();
}
protected function bootEvents()
{
foreach ($this->events() as $event => $listeners) {
foreach ($listeners as $listener) {
if ($listener instanceof JobPipeline) {
$listener = $listener->toListener();
}
Event::listen($event, $listener);
}
}
}
protected function mapRoutes()
{
$this->app->booted(function () {
if (file_exists(base_path('routes/tenant.php'))) {
Route::namespace(static::$controllerNamespace)
->group(base_path('routes/tenant.php'));
}
});
}
protected function makeTenancyMiddlewareHighestPriority()
{
$tenancyMiddleware = [
// Even higher priority than the initialization middleware
Middleware\PreventAccessFromCentralDomains::class,
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
Middleware\InitializeTenancyByDomainOrSubdomain::class,
Middleware\InitializeTenancyByPath::class,
Middleware\InitializeTenancyByRequestData::class,
];
foreach (array_reverse($tenancyMiddleware) as $middleware) {
$this->app[Kernel::class]->prependToMiddlewarePriority($middleware);
}
}
}
@@ -0,0 +1,352 @@
<?php
namespace App\Services\Charts;
use Carbon\Carbon;
use DB;
use Illuminate\Support\Facades\Log;
final class DashboardChartService
{
/**
* Build all dashboard charts in a Chart.js-friendly shape.
*
* Every chart method is defensive because the legacy WMS/M3 data source is
* an ODBC connection and some environments do not have IBM i available.
* The dashboard still renders cleanly with zeroed data instead of failing.
*
* @return array<string, mixed>
*/
public function dashboard(): array
{
return [
'metrics' => $this->metrics(),
'charts' => [
'pickingLastSevenDays' => $this->pickingLastSevenDays(),
'dispatchesLastSevenDays' => $this->dispatchesLastSevenDays(),
'stockMovementsByType' => $this->stockMovementsByType(),
'lowStockByWarehouse' => $this->lowStockByWarehouse(),
'warehouseUtilisation' => $this->warehouseUtilisation(),
'systemHealth' => $this->systemHealth(),
],
];
}
/**
* @return array<int, array{label: string, value: int|string, hint: string, icon: string}>
*/
private function metrics(): array
{
return [
[
'label' => 'Open pick waves',
'value' => $this->safeCount('ICM688M1', "A4PICKSTS IN ('B', 'P')"),
'hint' => 'Active or in-progress pick work',
'icon' => 'fa-box-open',
],
[
'label' => 'Pending manifests',
'value' => $this->safeCount('ICM705M1', "APSPARE2 IN ('', 'P')"),
'hint' => 'Waiting for close or confirmation',
'icon' => 'fa-truck-loading',
],
[
'label' => 'M3 errors',
'value' => $this->safeCount('ICM699M', "SHERROR = 'X'"),
'hint' => 'Messages needing attention',
'icon' => 'fa-exclamation-triangle',
],
[
'label' => 'Warehouses',
'value' => $this->safeCount('IC670M'),
'hint' => 'Known warehouse records',
'icon' => 'fa-warehouse',
],
];
}
/**
* @return array<string, mixed>
*/
private function pickingLastSevenDays(): array
{
$labels = $this->lastDaysLabels(7);
$data = $this->emptySeries($labels);
try {
$rows = DB::connection('odbc')->select(
'SELECT DATE(A5PICKCMTS) AS PICK_DATE, COUNT(*) AS TOTAL
FROM EWDAT.ICM688M2
WHERE A5PICKCMTS >= CURRENT DATE - 7 DAYS
GROUP BY DATE(A5PICKCMTS)
ORDER BY PICK_DATE',
);
foreach ($rows as $row) {
$key = Carbon::parse($row->PICK_DATE)->format('d M');
if (array_key_exists($key, $data)) {
$data[$key] = (int) $row->TOTAL;
}
}
} catch (\Throwable $exception) {
$this->logChartException($exception);
}
return $this->lineChart($labels, array_values($data), 'Pick lines completed');
}
/**
* @return array<string, mixed>
*/
private function dispatchesLastSevenDays(): array
{
$labels = $this->lastDaysLabels(7);
$data = $this->emptySeries($labels);
try {
$rows = DB::connection('odbc')->select(
"SELECT SHDT AS SHIP_DATE, COUNT(DISTINCT SHMACPKR) AS TOTAL
FROM EWDAT.ICM699M
WHERE SHTYPE = 'M3-Res-out'
AND SHSOURCE = 'CloseShipmentCoChargeAPI'
AND SHDATA LIKE 'Success%'
AND SHDT >= VARCHAR_FORMAT(CURRENT DATE - 7 DAYS, 'YYYYMMDD')
GROUP BY SHDT
ORDER BY SHDT",
);
foreach ($rows as $row) {
$key = Carbon::createFromFormat('Ymd', (string) $row->SHIP_DATE)->format('d M');
if (array_key_exists($key, $data)) {
$data[$key] = (int) $row->TOTAL;
}
}
} catch (\Throwable $exception) {
$this->logChartException($exception);
}
return $this->barChart($labels, array_values($data), 'Dispatches closed');
}
/**
* @return array<string, mixed>
*/
private function stockMovementsByType(): array
{
$labels = ['Transfer', 'Replenishment', 'Packing', 'Shipment', 'Adjustment'];
$data = [0, 0, 0, 0, 0];
try {
$rows = DB::connection('odbc')->select(
"SELECT TRIM(SHSOURCE) AS SOURCE_NAME, COUNT(*) AS TOTAL
FROM EWDAT.ICM699M
WHERE SHDT >= VARCHAR_FORMAT(CURRENT DATE - 30 DAYS, 'YYYYMMDD')
GROUP BY TRIM(SHSOURCE)
ORDER BY TOTAL DESC
FETCH FIRST 5 ROWS ONLY",
);
if (! empty($rows)) {
$labels = [];
$data = [];
foreach ($rows as $row) {
$labels[] = ucwords(strtolower(str_replace(['API', '_', '-'], [' API', ' ', ' '], trim($row->SOURCE_NAME))));
$data[] = (int) $row->TOTAL;
}
}
} catch (\Throwable $exception) {
$this->logChartException($exception);
}
return $this->doughnutChart($labels, $data, 'Movement messages');
}
/**
* @return array<string, mixed>
*/
private function lowStockByWarehouse(): array
{
$labels = ['WH 1', 'WH 2', 'WH 3', 'WH 4', 'WH 5'];
$data = [0, 0, 0, 0, 0];
try {
$rows = DB::connection('odbc')->select(
'SELECT TRIM(AT9WHS) AS WAREHOUSE, COUNT(*) AS TOTAL
FROM EWDAT.ICM709M
WHERE AT9QTY <= AT9WRN
GROUP BY TRIM(AT9WHS)
ORDER BY TOTAL DESC
FETCH FIRST 6 ROWS ONLY',
);
if (! empty($rows)) {
$labels = [];
$data = [];
foreach ($rows as $row) {
$labels[] = 'WH ' . trim($row->WAREHOUSE);
$data[] = (int) $row->TOTAL;
}
}
} catch (\Throwable $exception) {
$this->logChartException($exception);
}
return $this->barChart($labels, $data, 'Low-stock lines');
}
/**
* @return array<string, mixed>
*/
private function warehouseUtilisation(): array
{
$labels = ['Available', 'Allocated', 'Packed'];
$data = [0, 0, 0];
try {
$rows = DB::connection('odbc')->select(
'SELECT
SUM(A5RSVQTY) AS RESERVED_QTY,
SUM(A5PACKED) AS PACKED_QTY,
SUM(A5RSVQTY - A5PACKED) AS REMAINING_QTY
FROM EWDAT.ICM688M2
WHERE A5RSVQTY > 0',
);
if (! empty($rows)) {
$data = [
max(0, (int) $rows[0]->REMAINING_QTY),
max(0, (int) $rows[0]->RESERVED_QTY),
max(0, (int) $rows[0]->PACKED_QTY),
];
}
} catch (\Throwable $exception) {
$this->logChartException($exception);
}
return $this->doughnutChart($labels, $data, 'Quantity status');
}
/**
* @return array<string, mixed>
*/
private function systemHealth(): array
{
$labels = ['Success', 'Errors', 'Pending'];
$data = [0, 0, 0];
try {
$success = $this->safeCount('ICM699M', "SHTYPE = 'M3-Res-out' AND SHDATA LIKE 'Success%'");
$errors = $this->safeCount('ICM699M', "SHERROR = 'X'");
$pending = $this->safeCount('ICM705M1', "APSPARE2 IN ('', 'P')");
$data = [$success, $errors, $pending];
} catch (\Throwable $exception) {
$this->logChartException($exception);
}
return $this->doughnutChart($labels, $data, 'Operational status');
}
/**
* @return array<int, string>
*/
private function lastDaysLabels(int $days): array
{
$labels = [];
for ($offset = $days - 1; $offset >= 0; $offset--) {
$labels[] = Carbon::today()->subDays($offset)->format('d M');
}
return $labels;
}
/**
* @param array<int, string> $labels
*
* @return array<string, int>
*/
private function emptySeries(array $labels): array
{
return array_fill_keys($labels, 0);
}
private function safeCount(string $table, ?string $where = null): int
{
try {
$sql = 'SELECT COUNT(*) AS TOTAL FROM EWDAT.' . $table;
if ($where !== null && trim($where) !== '') {
$sql .= ' WHERE ' . $where;
}
$rows = DB::connection('odbc')->select($sql);
return isset($rows[0]->TOTAL) ? (int) $rows[0]->TOTAL : 0;
} catch (\Throwable $exception) {
$this->logChartException($exception);
return 0;
}
}
/**
* @param array<int, string> $labels
* @param array<int, int> $data
*
* @return array<string, mixed>
*/
private function lineChart(array $labels, array $data, string $label): array
{
return [
'labels' => $labels,
'datasets' => [[
'label' => $label,
'data' => $data,
'tension' => 0.35,
'fill' => true,
]],
];
}
/**
* @param array<int, string> $labels
* @param array<int, int> $data
*
* @return array<string, mixed>
*/
private function barChart(array $labels, array $data, string $label): array
{
return [
'labels' => $labels,
'datasets' => [[
'label' => $label,
'data' => $data,
'borderRadius' => 8,
]],
];
}
/**
* @param array<int, string> $labels
* @param array<int, int> $data
*
* @return array<string, mixed>
*/
private function doughnutChart(array $labels, array $data, string $label): array
{
return [
'labels' => $labels,
'datasets' => [[
'label' => $label,
'data' => $data,
'borderWidth' => 2,
'hoverOffset' => 8,
]],
];
}
private function logChartException(\Throwable $exception): void
{
Log::debug('Dashboard chart data unavailable.', [
'message' => $exception->getMessage(),
]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Process;
class AppVersion
{
public static function gitHash(): string
{
return trim(Process::run('git rev-parse --short HEAD')->output()) ?: 'unknown';
}
public static function gitBranch(): string
{
return trim(Process::run('git rev-parse --abbrev-ref HEAD')->output()) ?: 'unknown';
}
public static function buildDate(): string
{
return now()->format('Y-m-d H:i:s');
}
}