diff --git a/app/Adapters/Contacts/EloquentCustomerStatsProvider.php b/app/Adapters/Contacts/EloquentCustomerStatsProvider.php new file mode 100644 index 00000000..2f87aaaa --- /dev/null +++ b/app/Adapters/Contacts/EloquentCustomerStatsProvider.php @@ -0,0 +1,143 @@ + $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, + ]; + } +} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 00000000..f36f4090 --- /dev/null +++ b/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,30 @@ + + */ + protected $except = [ + 'login', + 'installation/session-login', + ]; +} diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php new file mode 100644 index 00000000..4bc7292e --- /dev/null +++ b/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,26 @@ +proxies = env('TRUSTED_PROXIES', '*'); + + return $this->proxies; + } +} diff --git a/app/Platform/Http/Controller.php b/app/Platform/Http/Controller.php new file mode 100644 index 00000000..248a1939 --- /dev/null +++ b/app/Platform/Http/Controller.php @@ -0,0 +1,26 @@ +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; +} diff --git a/app/Platform/Modules/Console/InstallModuleCommand.php b/app/Platform/Modules/Console/InstallModuleCommand.php new file mode 100644 index 00000000..f0c19742 --- /dev/null +++ b/app/Platform/Modules/Console/InstallModuleCommand.php @@ -0,0 +1,36 @@ +argument('module'); + $version = $this->argument('version'); + + ModuleInstaller::complete($name, $version); + + return self::SUCCESS; + } +} diff --git a/app/Platform/Modules/Events/ModuleDisabledEvent.php b/app/Platform/Modules/Events/ModuleDisabledEvent.php new file mode 100644 index 00000000..81e5f98f --- /dev/null +++ b/app/Platform/Modules/Events/ModuleDisabledEvent.php @@ -0,0 +1,28 @@ +module = $module; + } +} diff --git a/app/Platform/Modules/Events/ModuleEnabledEvent.php b/app/Platform/Modules/Events/ModuleEnabledEvent.php new file mode 100644 index 00000000..2970ae7b --- /dev/null +++ b/app/Platform/Modules/Events/ModuleEnabledEvent.php @@ -0,0 +1,28 @@ +module = $module; + } +} diff --git a/app/Platform/Modules/Events/ModuleInstalledEvent.php b/app/Platform/Modules/Events/ModuleInstalledEvent.php new file mode 100644 index 00000000..1c42c3bd --- /dev/null +++ b/app/Platform/Modules/Events/ModuleInstalledEvent.php @@ -0,0 +1,28 @@ +module = $module; + } +} diff --git a/app/Platform/Modules/Http/Controllers/Assets/ScriptController.php b/app/Platform/Modules/Http/Controllers/Assets/ScriptController.php new file mode 100644 index 00000000..5acdf9a1 --- /dev/null +++ b/app/Platform/Modules/Http/Controllers/Assets/ScriptController.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/app/Platform/Modules/Http/Controllers/Assets/StyleController.php b/app/Platform/Modules/Http/Controllers/Assets/StyleController.php new file mode 100644 index 00000000..60366426 --- /dev/null +++ b/app/Platform/Modules/Http/Controllers/Assets/StyleController.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/app/Platform/Modules/Http/Requests/UploadModuleRequest.php b/app/Platform/Modules/Http/Requests/UploadModuleRequest.php new file mode 100644 index 00000000..6bd06be8 --- /dev/null +++ b/app/Platform/Modules/Http/Requests/UploadModuleRequest.php @@ -0,0 +1,24 @@ +> + */ + public function rules(): array + { + return [ + 'avatar' => ['required', 'file', 'mimes:zip', 'max:20000'], + 'module' => ['required', 'string', 'max:100'], + ]; + } +} diff --git a/app/Platform/Modules/Http/Resources/ModuleCollection.php b/app/Platform/Modules/Http/Resources/ModuleCollection.php new file mode 100644 index 00000000..806adb7d --- /dev/null +++ b/app/Platform/Modules/Http/Resources/ModuleCollection.php @@ -0,0 +1,23 @@ + + */ + protected function casts(): array + { + return [ + 'installed' => 'boolean', + 'enabled' => 'boolean', + 'last_failed_at' => 'datetime', + ]; + } +} diff --git a/app/Platform/Modules/Runtime/ModuleInstaller.php b/app/Platform/Modules/Runtime/ModuleInstaller.php new file mode 100644 index 00000000..cee0bee2 --- /dev/null +++ b/app/Platform/Modules/Runtime/ModuleInstaller.php @@ -0,0 +1,34 @@ + $module], + ['version' => $version, 'installed' => true, 'enabled' => true] + ); + + event(new ModuleInstalledEvent($record)); + event(new ModuleEnabledEvent($record)); + + return true; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000..a8ae5722 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,128 @@ + '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']); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 00000000..708a703e --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,55 @@ +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)); + } +} diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php new file mode 100644 index 00000000..7cc89244 --- /dev/null +++ b/app/Providers/ViewServiceProvider.php @@ -0,0 +1,39 @@ +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.'); + } + } +} diff --git a/app/Rules/RelationNotExist.php b/app/Rules/RelationNotExist.php new file mode 100644 index 00000000..0294a2bd --- /dev/null +++ b/app/Rules/RelationNotExist.php @@ -0,0 +1,46 @@ +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."); + } + + } +} diff --git a/app/Support/Formatting/DateFormatter.php b/app/Support/Formatting/DateFormatter.php new file mode 100644 index 00000000..7cb88a8b --- /dev/null +++ b/app/Support/Formatting/DateFormatter.php @@ -0,0 +1,48 @@ +> + */ + 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 + ); + } +} diff --git a/app/Support/Formatting/TimeZones.php b/app/Support/Formatting/TimeZones.php new file mode 100644 index 00000000..a0b7030e --- /dev/null +++ b/app/Support/Formatting/TimeZones.php @@ -0,0 +1,468 @@ + '-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))); + } +} diff --git a/app/Support/Media/CustomPathGenerator.php b/app/Support/Media/CustomPathGenerator.php new file mode 100644 index 00000000..6bc63e94 --- /dev/null +++ b/app/Support/Media/CustomPathGenerator.php @@ -0,0 +1,70 @@ +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.'/'; + } +}