feat(app): fresh module-platform, provider, middleware, rule and support implementation

This commit is contained in:
Darko Gjorgjijoski
2026-08-21 14:15:49 +02:00
parent 56cb653a2d
commit 7d5f29a1d4
24 changed files with 1580 additions and 0 deletions
@@ -0,0 +1,143 @@
<?php
namespace App\Adapters\Contacts;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Contacts\Contracts\CustomerStatsProvider;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Purchases\Models\Expense;
use App\Domains\Receivables\Models\Payment;
use App\Domains\Sales\Models\Invoice;
use Carbon\Carbon;
/**
* One customer's fiscal year in numbers: twelve monthly buckets of invoiced,
* spent and received money, plus the totals for the window as a whole.
*
* The window opens on the month named by the first dash-separated part of the
* company's `fiscal_year` preference. Anything that part cannot be read as a
* number, and the shipped default, the word "calendar_year", is exactly that,
* intval()s to zero; month zero rolls Carbon back into December of the year
* before, so those companies get a window opening the previous December. It is
* a real, user-visible defect and it is reproduced here on purpose: the
* dashboard walks the same window the same way, and the two have to keep
* agreeing until they are fixed together.
*
* KNOWN QUIRK: only the fiscal-year lookup uses the $companyId that is passed
* in. Every figure below is scoped by whereCompany(), which reads the company
* header off the current request instead.
*/
class EloquentCustomerStatsProvider implements CustomerStatsProvider
{
public function get(Customer $customer, int $companyId, bool $previousYear = false): array
{
$openingMonth = intval(explode('-', CompanySetting::getSetting('fiscal_year', $companyId))[0]);
// Three cursors set to the same instant: the fixed left edge of the
// whole window, and the pair that walks it one month at a time.
$windowStart = Carbon::now();
$monthStart = Carbon::now();
$monthEnd = Carbon::now();
// An opening month still ahead of us in the calendar year belongs to
// the fiscal year that opened twelve months ago.
$openedLastYear = $openingMonth > $monthStart->month;
foreach ([$windowStart, $monthStart, $monthEnd] as $cursor) {
if ($openedLastYear) {
$cursor->subYear();
}
$cursor->month($openingMonth);
}
$windowStart->startOfMonth();
$monthStart->startOfMonth();
$monthEnd->endOfMonth();
if ($previousYear) {
$windowStart->subYear()->startOfMonth();
$monthStart->subYear()->startOfMonth();
$monthEnd->subYear()->endOfMonth();
}
$months = [];
$invoiceTotals = [];
$expenseTotals = [];
$receiptTotals = [];
$netProfits = [];
for ($bucket = 0; $bucket < 12; $bucket++) {
$bucketSpan = [$monthStart->format('Y-m-d'), $monthEnd->format('Y-m-d')];
$invoiceTotals[] = Invoice::query()
->whereBetween('invoice_date', $bucketSpan)
->whereCompany()
->whereCustomer($customer->id)
->sum('base_total');
$expenseTotals[] = Expense::query()
->whereBetween('expense_date', $bucketSpan)
->whereCompany()
->whereUser($customer->id)
->sum('base_amount');
$receiptTotals[] = Payment::query()
->whereBetween('payment_date', $bucketSpan)
->whereCompany()
->whereCustomer($customer->id)
->sum('base_amount');
// What was received less what was spent. Invoiced money is not in
// it: a bill that has not been paid is not profit.
$netProfits[] = $receiptTotals[$bucket] - $expenseTotals[$bucket];
$months[] = $monthStart->translatedFormat('M');
// Both cursors step off the first of their month, so a short month
// can never drag the walk backwards.
$monthEnd->startOfMonth()->addMonth()->endOfMonth();
$monthStart->addMonth()->startOfMonth();
}
// Twelve steps left the walking cursor on the month after the window.
// Back it on to the last month of the window and take that month's
// final day as the right edge of the whole-window figures.
$monthStart->subMonth()->endOfMonth();
$windowSpan = [$windowStart->format('Y-m-d'), $monthStart->format('Y-m-d')];
$salesTotal = Invoice::query()
->whereBetween('invoice_date', $windowSpan)
->whereCompany()
->whereCustomer($customer->id)
->sum('base_total');
$totalReceipts = Payment::query()
->whereBetween('payment_date', $windowSpan)
->whereCompany()
->whereCustomer($customer->id)
->sum('base_amount');
$totalExpenses = Expense::query()
->whereBetween('expense_date', $windowSpan)
->whereCompany()
->whereUser($customer->id)
->sum('base_amount');
return [
'months' => $months,
'invoiceTotals' => $invoiceTotals,
'expenseTotals' => $expenseTotals,
'receiptTotals' => $receiptTotals,
// KNOWN QUIRK: both sides are cut to whole units before the
// subtraction, so the headline figure loses the cents that the
// three totals beside it keep.
'netProfit' => (int) $totalReceipts - (int) $totalExpenses,
'netProfits' => $netProfits,
'salesTotal' => $salesTotal,
'totalReceipts' => $totalReceipts,
'totalExpenses' => $totalExpenses,
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as FrameworkEncryptCookies;
/**
* Application hook into the framework's cookie encryption pass.
*
* It exists so the app owns the opt-out list; nothing else is customised.
*/
class EncryptCookies extends FrameworkEncryptCookies
{
/**
* Whether cookie payloads are run through PHP's serializer before being
* encrypted. Left off, matching the framework default.
*
* @var bool
*/
protected static $serialize = false;
/**
* Cookie names that travel in clear text. Nothing is exempt right now.
*
* @var array
*/
protected $except = [
//
];
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as FrameworkPreventRequestForgery;
/**
* Application hook into the framework's CSRF token check.
*
* Only two endpoints opt out, both of them credential posts that are reached
* before a session token can reasonably be in hand.
*/
class PreventRequestForgery extends FrameworkPreventRequestForgery
{
/**
* Whether responses carry the readable XSRF-TOKEN cookie the SPA reads
* back when signing its own requests.
*
* @var bool
*/
protected $addHttpCookie = true;
/**
* Request paths the token check skips.
*
* @var array<int, string>
*/
protected $except = [
'login',
'installation/session-login',
];
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as FrameworkTrimStrings;
/**
* Application hook into the framework's whitespace-trimming pass over the
* request payload.
*/
class TrimStrings extends FrameworkTrimStrings
{
/**
* Input keys handed through untouched, since a leading or trailing space
* is a legitimate part of the value.
*
* Note this replaces the framework list rather than extending it, so
* `current_password` is trimmed here even though the framework spares it.
*
* @var array
*/
protected $except = [
'password',
'password_confirmation',
];
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as FrameworkTrustProxies;
use Illuminate\Http\Request;
/**
* Teaches the request object which upstream proxies may rewrite the client's
* address, host, port and scheme.
*/
class TrustProxies extends FrameworkTrustProxies
{
/**
* Proxies the application accepts forwarded headers from, resolved lazily
* by {@see self::proxies()}.
*
* @var array
*/
protected $proxies;
/**
* Bitmask of the forwarding headers that are honoured.
*
* @var array
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
| Request::HEADER_X_FORWARDED_AWS_ELB;
/**
* Resolve the trusted proxy list.
*
* Defaults to trusting every hop, which suits the containerised installs
* that sit behind an operator-controlled reverse proxy.
*
* @return string|array|null
*/
protected function proxies()
{
$this->proxies = env('TRUSTED_PROXIES', '*');
return $this->proxies;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Platform\Http;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
/**
* The class every controller in the application is built on.
*
* It adds no behaviour of its own. What it does is settle, in one place, the
* three helper sets a controller may assume are there: authorisation
* ($this->authorize(), authorizeForUser(), authorizeResource()), job dispatch
* ($this->dispatch(), dispatchSync()) and inline validation ($this->validate()).
* Controllers across the whole codebase call into all three, so the list below
* is a contract with them: dropping a trait breaks call sites far from here,
* and the framework's own base class deliberately ships without them.
*/
abstract class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Platform\Modules\Console;
use App\Platform\Modules\Runtime\ModuleInstaller;
use Illuminate\Console\Command;
/**
* Finishes an installation whose module files are already sitting on disk.
*/
class InstallModuleCommand extends Command
{
/** @var string */
protected $signature = 'install:module {module} {version}';
/** @var string */
protected $description = 'Install cloned module.';
public function __construct()
{
parent::__construct();
}
/**
* Hand the module name and version to the runtime installer.
*/
public function handle(): int
{
$name = $this->argument('module');
$version = $this->argument('version');
ModuleInstaller::complete($name, $version);
return self::SUCCESS;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Platform\Modules\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Announces that a module has been switched off for this installation.
*/
class ModuleDisabledEvent
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Subject of the announcement. Deliberately untyped: the property and the
* constructor parameter are a published contract for module listeners.
*/
public $module;
public function __construct($module)
{
$this->module = $module;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Platform\Modules\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Announces that a module has been switched on for this installation.
*/
class ModuleEnabledEvent
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Subject of the announcement. Deliberately untyped: the property and the
* constructor parameter are a published contract for module listeners.
*/
public $module;
public function __construct($module)
{
$this->module = $module;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Platform\Modules\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Announces that a module's files, migrations and registry row are in place.
*/
class ModuleInstalledEvent
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Subject of the announcement. Deliberately untyped: the property and the
* constructor parameter are a published contract for module listeners.
*/
public $module;
public function __construct($module)
{
$this->module = $module;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Platform\Modules\Http\Controllers\Assets;
use App\Platform\Http\Controller;
use App\Platform\Modules\Runtime\ModuleAssetVersion;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ScriptController extends Controller
{
/**
* Stream one script that a module published from its ServiceProvider::boot()
* through \InvoiceShelf\Modules\Registry::registerScript($name, $path).
*
* A "v" query value equal to the hash of the bytes being served earns a
* year-long immutable cache; every other request is answered no-store so a
* rebuilt asset is never hidden behind a stale copy.
*
* @throws NotFoundHttpException
*/
public function __invoke(Request $request, string $script): Response
{
$path = ModuleRegistry::scriptFor($script);
abort_if($path === null || ! is_file($path), 404);
$contents = file_get_contents($path);
abort_if(! is_string($contents), 404);
$version = ModuleAssetVersion::forContents($contents);
$requested = $request->query('v');
$matchesServedBytes = is_string($requested) && hash_equals($version, $requested);
$response = response($contents, 200, ['Content-Type' => 'application/javascript'])
->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
$response->headers->set(
'Cache-Control',
$matchesServedBytes ? 'public, max-age=31536000, immutable' : 'no-store'
);
return $response;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Platform\Modules\Http\Controllers\Assets;
use App\Platform\Http\Controller;
use App\Platform\Modules\Runtime\ModuleAssetVersion;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class StyleController extends Controller
{
/**
* Stream one stylesheet that a module published from its
* ServiceProvider::boot() through
* \InvoiceShelf\Modules\Registry::registerStyle($name, $path).
*
* A "v" query value equal to the hash of the bytes being served earns a
* year-long immutable cache; every other request is answered no-store so a
* rebuilt asset is never hidden behind a stale copy.
*
* @throws NotFoundHttpException
*/
public function __invoke(Request $request, string $style): Response
{
$path = ModuleRegistry::styleFor($style);
abort_if($path === null || ! is_file($path), 404);
$contents = file_get_contents($path);
abort_if(! is_string($contents), 404);
$version = ModuleAssetVersion::forContents($contents);
$requested = $request->query('v');
$matchesServedBytes = is_string($requested) && hash_equals($version, $requested);
$response = response($contents, 200, ['Content-Type' => 'text/css'])
->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
$response->headers->set(
'Cache-Control',
$matchesServedBytes ? 'public, max-age=31536000, immutable' : 'no-store'
);
return $response;
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Platform\Modules\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadModuleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, list<string>>
*/
public function rules(): array
{
return [
'avatar' => ['required', 'file', 'mimes:zip', 'max:20000'],
'module' => ['required', 'string', 'max:100'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Platform\Modules\Http\Resources;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ModuleCollection extends ResourceCollection
{
/**
* Hand back the default mapping over the wrapped module resources.
*
* @param Request $request
* @return array|Arrayable|\JsonSerializable
*/
public function toArray($request): array
{
$modules = parent::toArray($request);
return $modules;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Platform\Modules\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Registry row describing one module known to this installation.
*/
class Module extends Model
{
use HasFactory;
protected $table = 'modules';
protected $guarded = ['id'];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'installed' => 'boolean',
'enabled' => 'boolean',
'last_failed_at' => 'datetime',
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Platform\Modules\Runtime;
use App\Platform\Modules\Events\ModuleEnabledEvent;
use App\Platform\Modules\Events\ModuleInstalledEvent;
use App\Platform\Modules\Models\Module as ModelsModule;
use Illuminate\Support\Facades\Artisan;
use Nwidart\Modules\Facades\Module;
class ModuleInstaller
{
/**
* Migrate and activate a module already present on disk, write its registry
* row, then announce the install and the activation.
*/
public static function complete($module, $version): bool
{
Module::register();
Artisan::call(sprintf('module:migrate %s --force', $module));
Artisan::call(sprintf('module:enable %s', $module));
$record = ModelsModule::updateOrCreate(
['name' => $module],
['version' => $version, 'installed' => true, 'enabled' => true]
);
event(new ModuleInstalledEvent($record));
event(new ModuleEnabledEvent($record));
return true;
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Providers;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Persistence\ModelIdentityMap;
use App\Support\Bouncer\BouncerDefaultScope;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\ServiceProvider;
use Silber\Bouncer\Database\Models as BouncerModels;
class AppServiceProvider extends ServiceProvider
{
/**
* Where a signed-in staff user lands.
*
* The authentication layer redirects here once credentials check out.
*
* @var string
*/
public const HOME = '/admin/dashboard';
/**
* Where a signed-in portal customer lands.
*
* The customer guard redirects here once credentials check out.
*
* @var string
*/
public const CUSTOMER_HOME = '/customer/dashboard';
/**
* Boot the application-wide behaviour.
*/
public function boot(): void
{
ModelIdentityMap::enforce();
Factory::guessFactoryNamesUsing(
fn (string $model): string => 'Database\\Factories\\'.class_basename($model).'Factory'
);
// Navigation is built from config only once there is a schema to talk
// to; during a fresh install the tables do not exist yet.
if (InstallationState::isDbCreated()) {
$this->addMenus();
}
$this->bootBroadcast();
// The public demo build must never put real mail on the wire.
if (config('app.env') === 'demo') {
Mail::fake();
Notification::fake();
}
}
/**
* Register container bindings.
*/
public function register(): void
{
BouncerModels::scope(new BouncerDefaultScope);
}
/**
* Publish every navigation tree the SPA can ask for.
*
* Keys are the registered menu names; values are the config entries each
* one is built from. Note the customer portal menu is registered under a
* name that differs from its config key.
*/
public function addMenus()
{
$sources = [
'main_menu' => 'invoiceshelf.main_menu',
'admin_menu' => 'invoiceshelf.admin_menu',
'setting_menu' => 'invoiceshelf.setting_menu',
'customer_portal_menu' => 'invoiceshelf.customer_menu',
];
foreach ($sources as $name => $configKey) {
\Menu::make($name, function ($menu) use ($configKey) {
foreach (config($configKey) as $data) {
$this->generateMenu($menu, $data);
}
});
}
}
/**
* Append one configured entry to a menu under construction.
*
* Everything past the title and link rides along as item metadata, which
* is what the bootstrap endpoints filter and hand to the frontend.
*/
public function generateMenu($menu, $data)
{
$item = $menu->add($data['title'], $data['link']);
$meta = [
'icon' => $data['icon'],
'name' => $data['name'],
'owner_only' => $data['owner_only'],
'super_admin_only' => $data['super_admin_only'] ?? false,
'ability' => $data['ability'],
'model' => $data['model'],
'group' => $data['group'],
'group_label' => $data['group_label'] ?? '',
'priority' => $data['priority'] ?? 100,
];
foreach ($meta as $key => $value) {
$item->data($key, $value);
}
}
/**
* Expose the broadcasting auth endpoint behind the API guard.
*/
public function bootBroadcast()
{
Broadcast::routes(['middleware' => 'api.auth']);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as FrameworkRouteServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends FrameworkRouteServiceProvider
{
/**
* Where a signed-in staff user lands.
*
* The authentication layer redirects here once credentials check out.
*
* @var string
*/
public const HOME = '/admin/dashboard';
/**
* Where a signed-in portal customer lands.
*
* The customer guard redirects here once credentials check out.
*
* @var string
*/
public const CUSTOMER_HOME = '/customer/dashboard';
/**
* Install the throttling rules, then mount the route files.
*/
public function boot(): void
{
$this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api')
->middleware('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
/**
* Declare the named rate limiters this application offers.
*/
protected function configureRateLimiting(): void
{
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60));
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class ViewServiceProvider extends ServiceProvider
{
/**
* Register container bindings.
*/
public function register(): void
{
//
}
/**
* Hand every Blade view the branding values it may render.
*
* Each one is shared under the same name as the application setting it
* comes from, so the templates and the settings screen speak one
* vocabulary.
*/
public function boot(): void
{
$branding = [
'login_page_logo',
'login_page_heading',
'login_page_description',
'admin_page_title',
'copyright_text',
];
foreach ($branding as $setting) {
View::share($setting, get_app_setting($setting));
}
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Validates the JSON envelope the SPA posts for logo, avatar and receipt
* uploads: an object carrying a file `name` plus a `data:` URI whose payload
* is base64.
*
* Two checks run in sequence. First the declared side of the envelope (the
* filename extension and the shape of the data URI); then the bytes are
* decoded and sniffed. The sniff is deliberately allowed to clear a failure
* raised by the declared side, so a payload whose real type is acceptable is
* let through even when the filename disagrees.
*/
class Base64Mime implements ValidationRule
{
private $attribute;
private $extensions;
/**
* @param array $extensions File extensions considered acceptable.
* @return void
*/
public function __construct(array $extensions)
{
$this->extensions = $extensions;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$this->attribute = $attribute;
$failed = false;
try {
$envelope = json_decode(trim($value));
$name = ! empty($envelope->name) ? $envelope->name : '';
$uri = ! empty($envelope->data) ? $envelope->data : '';
} catch (\Exception $e) {
$failed = true;
}
if (! in_array(pathinfo($name, PATHINFO_EXTENSION), $this->extensions)) {
$failed = true;
}
if (! preg_match('/^data:\w+\/[\w\+]+;base64,[\w\+\=\/]+$/', $uri)) {
$failed = true;
}
$segments = explode(',', $uri);
if (! isset($segments[1]) || empty($segments[1])) {
$failed = true;
}
try {
$bytes = base64_decode($segments[1]);
$handle = finfo_open();
$sniffed = finfo_buffer($handle, $bytes, FILEINFO_EXTENSION);
if ($sniffed === '???') {
$failed = true;
}
// A sniff may answer with several equivalent extensions joined by
// slashes, e.g. "jpeg/jpg/jpe/jfif"; any one of them matching is
// enough to accept the upload.
if (strpos($sniffed, '/')) {
foreach (explode('/', $sniffed) as $candidate) {
if (in_array($candidate, $this->extensions)) {
$failed = false;
}
}
} else {
if (in_array($sniffed, $this->extensions)) {
$failed = false;
}
}
} catch (\Exception $e) {
$failed = true;
}
if ($failed) {
$fail('The '.$this->attribute.' must be a json with file of type: '.implode(', ', $this->extensions).' encoded in base64.');
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Guards bulk deletes: rejects an id whose record still has rows hanging off
* the named relation.
*
* Known defect, kept deliberately: an id with no matching record makes the
* lookup return null and the relation call raise, which surfaces as a 500
* rather than a validation failure. Call sites pair this with an existence
* check when they care.
*/
class RelationNotExist implements ValidationRule
{
public $class;
public $relation;
/**
* @param string|null $class Model to look the value up on.
* @param string|null $relation Relation method that must come back empty.
* @return void
*/
public function __construct(?string $class = null, ?string $relation = null)
{
$this->class = $class;
$this->relation = $relation;
}
/**
* Decide the value.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$method = $this->relation;
if ($this->class::find($value)->$method()->exists()) {
$fail("Relation {$this->relation} exists.");
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Support\Formatting;
use Carbon\Carbon;
/**
* The date layouts a company may choose between.
*
* Every layout is held twice, as a PHP pattern for whatever the server renders
* and as the moment.js pattern that matches it in the browser, so both sides
* write a saved date the same way round. What the settings screen shows is
* today's date put through the server pattern, in the company's own language.
*/
class DateFormatter
{
/**
* The offered layouts, server pattern beside its browser counterpart.
*
* KNOWN QUIRK: the moment pattern for 'Y/m/d' carries a leading space. It
* has been handed to clients that way for long enough to count as part of
* the payload, so it is left alone.
*
* @var array<int, array<string, string>>
*/
protected static $formats = [
['carbon_format' => 'Y M d', 'moment_format' => 'YYYY MMM DD'],
['carbon_format' => 'd M Y', 'moment_format' => 'DD MMM YYYY'],
['carbon_format' => 'd/m/Y', 'moment_format' => 'DD/MM/YYYY'],
['carbon_format' => 'd.m.Y', 'moment_format' => 'DD.MM.YYYY'],
['carbon_format' => 'd-m-Y', 'moment_format' => 'DD-MM-YYYY'],
['carbon_format' => 'm/d/Y', 'moment_format' => 'MM/DD/YYYY'],
['carbon_format' => 'Y/m/d', 'moment_format' => ' YYYY/MM/DD'],
['carbon_format' => 'Y-m-d', 'moment_format' => 'YYYY-MM-DD'],
];
public static function get_list()
{
return array_map(
fn (array $layout) => [
'display_date' => Carbon::now()->translatedFormat($layout['carbon_format']),
'carbon_format_value' => $layout['carbon_format'],
'moment_format_value' => $layout['moment_format'],
],
static::$formats
);
}
}
+468
View File
@@ -0,0 +1,468 @@
<?php
namespace App\Support\Formatting;
/**
* The zones a company can be set to, as the time-zone picker wants them.
*
* Two facts are kept per zone and nothing else: its IANA identifier, and how
* far standard time there runs from UTC. The label is composed from the pair,
* the offset in brackets followed by the place, and the place is read straight
* out of the identifier. Rows run west to east, ordered on the offset with the
* place name settling ties.
*
* Standard time is used deliberately, so that an entry cannot re-label itself
* twice a year, and the table is deliberately frozen: identifiers the database
* has since renamed are still what older companies have saved against, and
* re-basing a zone here would silently move every one of them.
*/
class TimeZones
{
public static function get_list()
{
$zones = [
'Pacific/Midway' => '-11:00',
'Pacific/Niue' => '-11:00',
'Pacific/Pago_Pago' => '-11:00',
'America/Adak' => '-10:00',
'Pacific/Honolulu' => '-10:00',
'Pacific/Johnston' => '-10:00',
'Pacific/Rarotonga' => '-10:00',
'Pacific/Tahiti' => '-10:00',
'Pacific/Marquesas' => '-09:30',
'America/Anchorage' => '-09:00',
'Pacific/Gambier' => '-09:00',
'America/Juneau' => '-09:00',
'America/Nome' => '-09:00',
'America/Sitka' => '-09:00',
'America/Yakutat' => '-09:00',
'America/Dawson' => '-08:00',
'America/Los_Angeles' => '-08:00',
'America/Metlakatla' => '-08:00',
'Pacific/Pitcairn' => '-08:00',
'America/Santa_Isabel' => '-08:00',
'America/Tijuana' => '-08:00',
'America/Vancouver' => '-08:00',
'America/Whitehorse' => '-08:00',
'America/Boise' => '-07:00',
'America/Cambridge_Bay' => '-07:00',
'America/Chihuahua' => '-07:00',
'America/Creston' => '-07:00',
'America/Dawson_Creek' => '-07:00',
'America/Denver' => '-07:00',
'America/Edmonton' => '-07:00',
'America/Hermosillo' => '-07:00',
'America/Inuvik' => '-07:00',
'America/Mazatlan' => '-07:00',
'America/Ojinaga' => '-07:00',
'America/Phoenix' => '-07:00',
'America/Shiprock' => '-07:00',
'America/Yellowknife' => '-07:00',
'America/Bahia_Banderas' => '-06:00',
'America/Belize' => '-06:00',
'America/North_Dakota/Beulah' => '-06:00',
'America/Cancun' => '-06:00',
'America/North_Dakota/Center' => '-06:00',
'America/Chicago' => '-06:00',
'America/Costa_Rica' => '-06:00',
'Pacific/Easter' => '-06:00',
'America/El_Salvador' => '-06:00',
'Pacific/Galapagos' => '-06:00',
'America/Guatemala' => '-06:00',
'America/Indiana/Knox' => '-06:00',
'America/Managua' => '-06:00',
'America/Matamoros' => '-06:00',
'America/Menominee' => '-06:00',
'America/Merida' => '-06:00',
'America/Mexico_City' => '-06:00',
'America/Monterrey' => '-06:00',
'America/North_Dakota/New_Salem' => '-06:00',
'America/Rainy_River' => '-06:00',
'America/Rankin_Inlet' => '-06:00',
'America/Regina' => '-06:00',
'America/Resolute' => '-06:00',
'America/Swift_Current' => '-06:00',
'America/Tegucigalpa' => '-06:00',
'America/Indiana/Tell_City' => '-06:00',
'America/Winnipeg' => '-06:00',
'America/Atikokan' => '-05:00',
'America/Bogota' => '-05:00',
'America/Cayman' => '-05:00',
'America/Detroit' => '-05:00',
'America/Grand_Turk' => '-05:00',
'America/Guayaquil' => '-05:00',
'America/Havana' => '-05:00',
'America/Indiana/Indianapolis' => '-05:00',
'America/Iqaluit' => '-05:00',
'America/Jamaica' => '-05:00',
'America/Lima' => '-05:00',
'America/Kentucky/Louisville' => '-05:00',
'America/Indiana/Marengo' => '-05:00',
'America/Kentucky/Monticello' => '-05:00',
'America/Montreal' => '-05:00',
'America/Nassau' => '-05:00',
'America/New_York' => '-05:00',
'America/Nipigon' => '-05:00',
'America/Panama' => '-05:00',
'America/Pangnirtung' => '-05:00',
'America/Indiana/Petersburg' => '-05:00',
'America/Port-au-Prince' => '-05:00',
'America/Thunder_Bay' => '-05:00',
'America/Toronto' => '-05:00',
'America/Indiana/Vevay' => '-05:00',
'America/Indiana/Vincennes' => '-05:00',
'America/Indiana/Winamac' => '-05:00',
'America/Caracas' => '-04:30',
'America/Anguilla' => '-04:00',
'America/Antigua' => '-04:00',
'America/Aruba' => '-04:00',
'America/Asuncion' => '-04:00',
'America/Barbados' => '-04:00',
'Atlantic/Bermuda' => '-04:00',
'America/Blanc-Sablon' => '-04:00',
'America/Boa_Vista' => '-04:00',
'America/Campo_Grande' => '-04:00',
'America/Cuiaba' => '-04:00',
'America/Curacao' => '-04:00',
'America/Dominica' => '-04:00',
'America/Eirunepe' => '-04:00',
'America/Glace_Bay' => '-04:00',
'America/Goose_Bay' => '-04:00',
'America/Grenada' => '-04:00',
'America/Guadeloupe' => '-04:00',
'America/Guyana' => '-04:00',
'America/Halifax' => '-04:00',
'America/Kralendijk' => '-04:00',
'America/La_Paz' => '-04:00',
'America/Lower_Princes' => '-04:00',
'America/Manaus' => '-04:00',
'America/Marigot' => '-04:00',
'America/Martinique' => '-04:00',
'America/Moncton' => '-04:00',
'America/Montserrat' => '-04:00',
'Antarctica/Palmer' => '-04:00',
'America/Port_of_Spain' => '-04:00',
'America/Porto_Velho' => '-04:00',
'America/Puerto_Rico' => '-04:00',
'America/Rio_Branco' => '-04:00',
'America/Santiago' => '-04:00',
'America/Santo_Domingo' => '-04:00',
'America/St_Barthelemy' => '-04:00',
'America/St_Kitts' => '-04:00',
'America/St_Lucia' => '-04:00',
'America/St_Thomas' => '-04:00',
'America/St_Vincent' => '-04:00',
'America/Thule' => '-04:00',
'America/Tortola' => '-04:00',
'America/St_Johns' => '-03:30',
'America/Araguaina' => '-03:00',
'America/Bahia' => '-03:00',
'America/Belem' => '-03:00',
'America/Argentina/Buenos_Aires' => '-03:00',
'America/Argentina/Catamarca' => '-03:00',
'America/Cayenne' => '-03:00',
'America/Argentina/Cordoba' => '-03:00',
'America/Fortaleza' => '-03:00',
'America/Godthab' => '-03:00',
'America/Argentina/Jujuy' => '-03:00',
'America/Argentina/La_Rioja' => '-03:00',
'America/Maceio' => '-03:00',
'America/Argentina/Mendoza' => '-03:00',
'America/Miquelon' => '-03:00',
'America/Montevideo' => '-03:00',
'America/Paramaribo' => '-03:00',
'America/Recife' => '-03:00',
'America/Argentina/Rio_Gallegos' => '-03:00',
'Antarctica/Rothera' => '-03:00',
'America/Argentina/Salta' => '-03:00',
'America/Argentina/San_Juan' => '-03:00',
'America/Argentina/San_Luis' => '-03:00',
'America/Santarem' => '-03:00',
'America/Sao_Paulo' => '-03:00',
'Atlantic/Stanley' => '-03:00',
'America/Argentina/Tucuman' => '-03:00',
'America/Argentina/Ushuaia' => '-03:00',
'America/Noronha' => '-02:00',
'Atlantic/South_Georgia' => '-02:00',
'Atlantic/Azores' => '-01:00',
'Atlantic/Cape_Verde' => '-01:00',
'America/Scoresbysund' => '-01:00',
'Africa/Abidjan' => '+00:00',
'Africa/Accra' => '+00:00',
'Africa/Bamako' => '+00:00',
'Africa/Banjul' => '+00:00',
'Africa/Bissau' => '+00:00',
'Atlantic/Canary' => '+00:00',
'Africa/Casablanca' => '+00:00',
'Africa/Conakry' => '+00:00',
'Africa/Dakar' => '+00:00',
'America/Danmarkshavn' => '+00:00',
'Europe/Dublin' => '+00:00',
'Africa/El_Aaiun' => '+00:00',
'Atlantic/Faroe' => '+00:00',
'Africa/Freetown' => '+00:00',
'Europe/Guernsey' => '+00:00',
'Europe/Isle_of_Man' => '+00:00',
'Europe/Jersey' => '+00:00',
'Europe/Lisbon' => '+00:00',
'Africa/Lome' => '+00:00',
'Europe/London' => '+00:00',
'Atlantic/Madeira' => '+00:00',
'Africa/Monrovia' => '+00:00',
'Africa/Nouakchott' => '+00:00',
'Africa/Ouagadougou' => '+00:00',
'Atlantic/Reykjavik' => '+00:00',
'Africa/Sao_Tome' => '+00:00',
'Atlantic/St_Helena' => '+00:00',
'UTC' => '+00:00',
'Africa/Algiers' => '+01:00',
'Europe/Amsterdam' => '+01:00',
'Europe/Andorra' => '+01:00',
'Africa/Bangui' => '+01:00',
'Europe/Belgrade' => '+01:00',
'Europe/Berlin' => '+01:00',
'Europe/Bratislava' => '+01:00',
'Africa/Brazzaville' => '+01:00',
'Europe/Brussels' => '+01:00',
'Europe/Budapest' => '+01:00',
'Europe/Busingen' => '+01:00',
'Africa/Ceuta' => '+01:00',
'Europe/Copenhagen' => '+01:00',
'Africa/Douala' => '+01:00',
'Europe/Gibraltar' => '+01:00',
'Africa/Kinshasa' => '+01:00',
'Africa/Lagos' => '+01:00',
'Africa/Libreville' => '+01:00',
'Europe/Ljubljana' => '+01:00',
'Arctic/Longyearbyen' => '+01:00',
'Africa/Luanda' => '+01:00',
'Europe/Luxembourg' => '+01:00',
'Europe/Madrid' => '+01:00',
'Africa/Malabo' => '+01:00',
'Europe/Malta' => '+01:00',
'Europe/Monaco' => '+01:00',
'Africa/Ndjamena' => '+01:00',
'Africa/Niamey' => '+01:00',
'Europe/Oslo' => '+01:00',
'Europe/Paris' => '+01:00',
'Europe/Podgorica' => '+01:00',
'Africa/Porto-Novo' => '+01:00',
'Europe/Prague' => '+01:00',
'Europe/Rome' => '+01:00',
'Europe/San_Marino' => '+01:00',
'Europe/Sarajevo' => '+01:00',
'Europe/Skopje' => '+01:00',
'Europe/Stockholm' => '+01:00',
'Europe/Tirane' => '+01:00',
'Africa/Tripoli' => '+01:00',
'Africa/Tunis' => '+01:00',
'Europe/Vaduz' => '+01:00',
'Europe/Vatican' => '+01:00',
'Europe/Vienna' => '+01:00',
'Europe/Warsaw' => '+01:00',
'Africa/Windhoek' => '+01:00',
'Europe/Zagreb' => '+01:00',
'Europe/Zurich' => '+01:00',
'Europe/Athens' => '+02:00',
'Asia/Beirut' => '+02:00',
'Africa/Blantyre' => '+02:00',
'Europe/Bucharest' => '+02:00',
'Africa/Bujumbura' => '+02:00',
'Africa/Cairo' => '+02:00',
'Europe/Chisinau' => '+02:00',
'Asia/Damascus' => '+02:00',
'Africa/Gaborone' => '+02:00',
'Asia/Gaza' => '+02:00',
'Africa/Harare' => '+02:00',
'Asia/Hebron' => '+02:00',
'Europe/Helsinki' => '+02:00',
'Europe/Istanbul' => '+02:00',
'Asia/Jerusalem' => '+02:00',
'Africa/Johannesburg' => '+02:00',
'Europe/Kiev' => '+02:00',
'Africa/Kigali' => '+02:00',
'Africa/Lubumbashi' => '+02:00',
'Africa/Lusaka' => '+02:00',
'Africa/Maputo' => '+02:00',
'Europe/Mariehamn' => '+02:00',
'Africa/Maseru' => '+02:00',
'Africa/Mbabane' => '+02:00',
'Asia/Nicosia' => '+02:00',
'Europe/Riga' => '+02:00',
'Europe/Simferopol' => '+02:00',
'Europe/Sofia' => '+02:00',
'Europe/Tallinn' => '+02:00',
'Europe/Uzhgorod' => '+02:00',
'Europe/Vilnius' => '+02:00',
'Europe/Zaporozhye' => '+02:00',
'Africa/Addis_Ababa' => '+03:00',
'Asia/Aden' => '+03:00',
'Asia/Amman' => '+03:00',
'Indian/Antananarivo' => '+03:00',
'Africa/Asmara' => '+03:00',
'Asia/Baghdad' => '+03:00',
'Asia/Bahrain' => '+03:00',
'Indian/Comoro' => '+03:00',
'Africa/Dar_es_Salaam' => '+03:00',
'Africa/Djibouti' => '+03:00',
'Africa/Juba' => '+03:00',
'Europe/Kaliningrad' => '+03:00',
'Africa/Kampala' => '+03:00',
'Africa/Khartoum' => '+03:00',
'Asia/Kuwait' => '+03:00',
'Indian/Mayotte' => '+03:00',
'Europe/Minsk' => '+03:00',
'Africa/Mogadishu' => '+03:00',
'Africa/Nairobi' => '+03:00',
'Asia/Qatar' => '+03:00',
'Asia/Riyadh' => '+03:00',
'Antarctica/Syowa' => '+03:00',
'Asia/Tehran' => '+03:30',
'Asia/Baku' => '+04:00',
'Asia/Dubai' => '+04:00',
'Indian/Mahe' => '+04:00',
'Indian/Mauritius' => '+04:00',
'Europe/Moscow' => '+04:00',
'Asia/Muscat' => '+04:00',
'Indian/Reunion' => '+04:00',
'Europe/Samara' => '+04:00',
'Asia/Tbilisi' => '+04:00',
'Europe/Volgograd' => '+04:00',
'Asia/Yerevan' => '+04:00',
'Asia/Kabul' => '+04:30',
'Asia/Aqtau' => '+05:00',
'Asia/Aqtobe' => '+05:00',
'Asia/Ashgabat' => '+05:00',
'Asia/Dushanbe' => '+05:00',
'Asia/Karachi' => '+05:00',
'Indian/Kerguelen' => '+05:00',
'Indian/Maldives' => '+05:00',
'Antarctica/Mawson' => '+05:00',
'Asia/Oral' => '+05:00',
'Asia/Samarkand' => '+05:00',
'Asia/Tashkent' => '+05:00',
'Asia/Colombo' => '+05:30',
'Asia/Kolkata' => '+05:30',
'Asia/Kathmandu' => '+05:45',
'Asia/Almaty' => '+06:00',
'Asia/Bishkek' => '+06:00',
'Indian/Chagos' => '+06:00',
'Asia/Dhaka' => '+06:00',
'Asia/Qyzylorda' => '+06:00',
'Asia/Thimphu' => '+06:00',
'Antarctica/Vostok' => '+06:00',
'Asia/Yekaterinburg' => '+06:00',
'Indian/Cocos' => '+06:30',
'Asia/Rangoon' => '+06:30',
'Asia/Bangkok' => '+07:00',
'Indian/Christmas' => '+07:00',
'Antarctica/Davis' => '+07:00',
'Asia/Ho_Chi_Minh' => '+07:00',
'Asia/Hovd' => '+07:00',
'Asia/Jakarta' => '+07:00',
'Asia/Novokuznetsk' => '+07:00',
'Asia/Novosibirsk' => '+07:00',
'Asia/Omsk' => '+07:00',
'Asia/Phnom_Penh' => '+07:00',
'Asia/Pontianak' => '+07:00',
'Asia/Vientiane' => '+07:00',
'Asia/Brunei' => '+08:00',
'Antarctica/Casey' => '+08:00',
'Asia/Choibalsan' => '+08:00',
'Asia/Chongqing' => '+08:00',
'Asia/Harbin' => '+08:00',
'Asia/Hong_Kong' => '+08:00',
'Asia/Kashgar' => '+08:00',
'Asia/Krasnoyarsk' => '+08:00',
'Asia/Kuala_Lumpur' => '+08:00',
'Asia/Kuching' => '+08:00',
'Asia/Macau' => '+08:00',
'Asia/Makassar' => '+08:00',
'Asia/Manila' => '+08:00',
'Australia/Perth' => '+08:00',
'Asia/Shanghai' => '+08:00',
'Asia/Singapore' => '+08:00',
'Asia/Taipei' => '+08:00',
'Asia/Ulaanbaatar' => '+08:00',
'Asia/Urumqi' => '+08:00',
'Australia/Eucla' => '+08:45',
'Asia/Dili' => '+09:00',
'Asia/Irkutsk' => '+09:00',
'Asia/Jayapura' => '+09:00',
'Pacific/Palau' => '+09:00',
'Asia/Pyongyang' => '+09:00',
'Asia/Seoul' => '+09:00',
'Asia/Tokyo' => '+09:00',
'Australia/Adelaide' => '+09:30',
'Australia/Broken_Hill' => '+09:30',
'Australia/Darwin' => '+09:30',
'Australia/Brisbane' => '+10:00',
'Pacific/Chuuk' => '+10:00',
'Australia/Currie' => '+10:00',
'Antarctica/DumontDUrville' => '+10:00',
'Pacific/Guam' => '+10:00',
'Australia/Hobart' => '+10:00',
'Asia/Khandyga' => '+10:00',
'Australia/Lindeman' => '+10:00',
'Australia/Melbourne' => '+10:00',
'Pacific/Port_Moresby' => '+10:00',
'Pacific/Saipan' => '+10:00',
'Australia/Sydney' => '+10:00',
'Asia/Yakutsk' => '+10:00',
'Australia/Lord_Howe' => '+10:30',
'Pacific/Efate' => '+11:00',
'Pacific/Guadalcanal' => '+11:00',
'Pacific/Kosrae' => '+11:00',
'Antarctica/Macquarie' => '+11:00',
'Pacific/Noumea' => '+11:00',
'Pacific/Pohnpei' => '+11:00',
'Asia/Sakhalin' => '+11:00',
'Asia/Ust-Nera' => '+11:00',
'Asia/Vladivostok' => '+11:00',
'Pacific/Norfolk' => '+11:30',
'Asia/Anadyr' => '+12:00',
'Pacific/Auckland' => '+12:00',
'Pacific/Fiji' => '+12:00',
'Pacific/Funafuti' => '+12:00',
'Asia/Kamchatka' => '+12:00',
'Pacific/Kwajalein' => '+12:00',
'Asia/Magadan' => '+12:00',
'Pacific/Majuro' => '+12:00',
'Antarctica/McMurdo' => '+12:00',
'Pacific/Nauru' => '+12:00',
'Antarctica/South_Pole' => '+12:00',
'Pacific/Tarawa' => '+12:00',
'Pacific/Wake' => '+12:00',
'Pacific/Wallis' => '+12:00',
'Pacific/Chatham' => '+12:45',
'Pacific/Apia' => '+13:00',
'Pacific/Enderbury' => '+13:00',
'Pacific/Fakaofo' => '+13:00',
'Pacific/Tongatapu' => '+13:00',
'Pacific/Kiritimati' => '+14:00',
];
$list = [];
foreach ($zones as $zone => $offset) {
$list[] = [
'value' => $zone,
'key' => sprintf('(UTC%s) %s', $offset, self::place($zone)),
];
}
return $list;
}
/**
* The place an identifier names: whatever follows the last slash, read
* with spaces instead of underscores. Saints keep the stop the
* abbreviation is written with in prose.
*/
private static function place(string $zone): string
{
$segments = explode('/', $zone);
return preg_replace('/^St /', 'St. ', str_replace('_', ' ', end($segments)));
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Media;
use App\Platform\Persistence\ModelIdentityMap;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
/**
* Decides where an upload sits on whichever disk the media library is writing
* to.
*
* Attachments of the three document kinds are pooled, one folder per kind;
* anything else is given a folder to itself, named after the media row's own
* id. Generated conversions and responsive variants go in a sub-folder of
* whichever folder the original landed in.
*
* These layouts are an on-disk contract rather than a preference. Installs
* already have files sitting at exactly these paths, and nothing rewrites them,
* so renaming a folder here loses whatever is stored below it. That is why the
* conversions sub-folder is still spelled "conversations": it is a typo, it has
* always been the typo, and correcting it would strand every conversion ever
* written.
*/
class CustomPathGenerator implements PathGenerator
{
public function getPath(Media $media): string
{
return $this->folder($media);
}
public function getPathForConversions(Media $media): string
{
return $this->folder($media, 'conversations');
}
public function getPathForResponsiveImages(Media $media): string
{
return $this->folder($media, 'responsive-images');
}
/**
* The folder shared by everything belonging to the given media.
*
* The fallback hands back the row's key as it comes, an integer, and lets
* the declared return type do the widening. A media row without a key
* therefore fails here rather than filing itself under an empty name,
* which is the behaviour that has always been in place.
*/
protected function getBasePath(Media $media): string
{
return match ($media->model_type) {
ModelIdentityMap::INVOICE_ALIAS => 'Invoices',
ModelIdentityMap::ESTIMATE_ALIAS => 'Estimates',
ModelIdentityMap::PAYMENT_ALIAS => 'Payments',
default => $media->getKey(),
};
}
/**
* That folder, or one named sub-folder of it, closed off with the
* separator the media library expects to find at the end.
*/
private function folder(Media $media, ?string $subFolder = null): string
{
$path = $this->getBasePath($media).'/';
return $subFolder === null ? $path : $path.$subFolder.'/';
}
}