Files
InvoiceShelf/app/Platform/Operations/Http/Company/BootstrapController.php
T
Darko Gjorgjijoski 7db2a8d094 feat(operations): fresh platform-operations implementation
Full rewrite of the installer (incl. the finish step), self-update pipeline,
settings store, cron webhook, dashboard/bootstrap surface, environment
manager, wizard login, config endpoint and shared helpers. Byte-compatible
public interfaces and observable behavior, including documented legacy
quirks; verified by the pilot behavioral suite and the full test suite.
2026-08-20 18:30:12 +02:00

216 lines
7.3 KiB
PHP

<?php
namespace App\Platform\Operations\Http\Company;
use App\Domains\Accounts\Http\Resources\CompanyInvitationResource;
use App\Domains\Accounts\Http\Resources\CompanyResource;
use App\Domains\Accounts\Http\Resources\UserResource;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanyInvitation;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Money\Models\Currency;
use App\Platform\Http\Controller;
use App\Platform\Modules\Models\Module;
use App\Platform\Operations\Http\Concerns\GeneratesMenu;
use App\Platform\Operations\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Silber\Bouncer\BouncerFacade;
/**
* The single round trip that hydrates the admin SPA on load: who is signed in,
* which workspace they are looking at, and what navigation they may see.
*
* Three shapes come out of here -- platform administration, a user who belongs
* to no company yet, and the ordinary company view -- built from one common
* envelope so the shared keys cannot drift apart.
*/
class BootstrapController extends Controller
{
use GeneratesMenu;
/**
* Instance-wide settings the shell needs before it can paint anything.
*
* Deliberately an allow-list rather than a dump of the settings table:
* credentials and tokens stored alongside these must not reach a browser.
*/
private const SHELL_SETTINGS = [
'admin_portal_theme',
'admin_portal_logo',
'login_page_logo',
'login_page_heading',
'login_page_description',
'admin_page_title',
'copyright_text',
'save_pdf_to_disk',
'show_sidebar_group_labels',
];
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(Request $request)
{
$user = $request->user();
$memberships = $user->companies;
if ($user->isSuperAdmin() && $request->has('admin_mode')) {
return response()->json($this->administrationView($user, $memberships));
}
if ($memberships->isEmpty()) {
return response()->json($this->envelope($user, Currency::first()));
}
return response()->json($this->companyView($request, $user, $memberships));
}
/**
* Re-read the workspace named by the company header. No membership check
* here; the SPA calls it right after it has switched companies.
*/
public function currentCompany(Request $request)
{
return new CompanyResource(Company::find($request->header('company')));
}
/**
* Everything every variant carries, with the company-scoped slots empty.
* Each variant fills in the ones that apply to it.
*/
private function envelope($user, ?Currency $currency): array
{
return [
'current_user' => new UserResource($user),
'current_user_settings' => $user->getAllSettings(),
'current_user_abilities' => [],
'companies' => [],
'current_company' => null,
'current_company_settings' => [],
'current_company_currency' => $currency,
'config' => config('invoiceshelf'),
'global_settings' => Setting::getSettings(self::SHELL_SETTINGS),
'main_menu' => [],
'setting_menu' => [],
'modules' => [],
'pending_invitations' => CompanyInvitationResource::collection(
$this->openInvitations($user)
),
];
}
/**
* Platform administration: no workspace is selected, so the payload swaps
* in the admin navigation and lists every company the admin belongs to.
*/
private function administrationView($user, $memberships): array
{
return array_merge($this->envelope($user, Currency::first()), [
'companies' => CompanyResource::collection($memberships),
'main_menu' => $this->generateMenu('admin_menu', $user),
'admin_mode' => true,
]);
}
/**
* The ordinary view, scoped to one company.
*/
private function companyView(Request $request, $user, $memberships): array
{
// Both menus are resolved against the abilities Bouncer has cached so
// far, i.e. before the refresh further down. Keep that order.
$mainMenu = $this->mainMenuWithModules($user);
$settingMenu = $this->generateMenu('setting_menu', $user);
$company = $this->activeCompany($request, $user);
$companySettings = CompanySetting::getAllSettings($company->id);
$currency = $companySettings->has('currency')
? Currency::find($companySettings->get('currency'))
: Currency::first();
BouncerFacade::refreshFor($user);
return array_merge($this->envelope($user, $currency), [
'current_user_abilities' => $user->getAbilities(),
'companies' => CompanyResource::collection($memberships),
'current_company' => new CompanyResource($company),
'current_company_settings' => $companySettings,
'main_menu' => $mainMenu,
'setting_menu' => $settingMenu,
'modules' => Module::where('enabled', true)->pluck('name'),
'user_menu' => $this->moduleUserMenu(),
]);
}
/**
* The workspace this request talks about: the one named by the company
* header when the user is actually a member of it, their first otherwise.
*/
private function activeCompany(Request $request, $user): ?Company
{
$requested = Company::find($request->header('company'));
if ($requested && $user->hasCompany($requested->id)) {
return $requested;
}
return $user->companies()->first();
}
/**
* Main navigation with module-registered entries appended. They join the
* same list so the frontend orders core and module items in one pass.
*/
private function mainMenuWithModules($user): array
{
$menu = $this->generateMenu('main_menu', $user);
foreach (ModuleRegistry::allMenu() as $slug => $entry) {
$menu[] = [
'title' => __($entry['title']),
'link' => $entry['link'],
'icon' => $entry['icon'],
'name' => 'module-'.$slug,
'group' => $entry['group'] ?? 'modules',
'group_label' => $entry['group_label'] ?? 'navigation.modules',
'priority' => $entry['priority'] ?? 100,
];
}
return $menu;
}
/**
* Module entries for the account dropdown, lightest priority first.
*/
private function moduleUserMenu(): array
{
return collect(ModuleRegistry::allUserMenu())
->map(fn (array $entry, string $slug): array => [
...$entry,
'title' => __($entry['title']),
'name' => 'module-'.$slug,
])
->sortBy('priority')
->values()
->all();
}
/**
* Invitations still awaiting this user's answer, with everything the
* frontend needs to describe them eager-loaded.
*/
private function openInvitations($user)
{
return CompanyInvitation::forUser($user)
->pending()
->with(['company', 'role', 'invitedBy'])
->get();
}
}