create-nevela 0.7.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +17 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +223 -0
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +208 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Nevela\Laravel\Generator;
|
|
4
4
|
|
|
5
|
+
use Nevela\Laravel\Support\Action;
|
|
5
6
|
use Nevela\Laravel\Support\Descriptor;
|
|
6
7
|
use Nevela\Laravel\Support\Field;
|
|
7
8
|
use Nevela\Laravel\Support\Naming;
|
|
9
|
+
use Nevela\Laravel\Support\PublicAccess;
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Turns descriptors into files. Pure: no Laravel, no filesystem — Writer does the I/O.
|
|
@@ -27,19 +29,64 @@ final class ResourceGenerator
|
|
|
27
29
|
{
|
|
28
30
|
$timestamp ??= date('Y_m_d_His');
|
|
29
31
|
$api = GeneratedFile::TARGET_API;
|
|
32
|
+
// An adopted table is already there, made by a migration of the app's own.
|
|
33
|
+
$migration = $d->adopted ? [] : [
|
|
34
|
+
new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d, $all), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
|
|
35
|
+
];
|
|
30
36
|
|
|
31
37
|
return [
|
|
32
38
|
new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d, $all)),
|
|
33
|
-
|
|
39
|
+
...$migration,
|
|
34
40
|
new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d, $all)),
|
|
35
|
-
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
|
|
41
|
+
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d, $all)),
|
|
36
42
|
new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d, $all)),
|
|
37
43
|
new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
|
|
38
44
|
new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d, $all)),
|
|
39
45
|
...$this->pages($d),
|
|
46
|
+
...array_values(array_map(fn (Action $action) => new GeneratedFile($api, "app/Nevela/Actions/{$d->name}/{$action->className()}.php", $this->action($d, $action), GeneratedFile::MODE_ONCE), $d->actions)),
|
|
40
47
|
];
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The class an action is written in: what happens when its button is pressed. Written
|
|
52
|
+
* once and then the app's own, like a policy. Until someone fills it in it says so.
|
|
53
|
+
*/
|
|
54
|
+
public function action(Descriptor $d, Action $action): string
|
|
55
|
+
{
|
|
56
|
+
$var = $d->variable();
|
|
57
|
+
$class = $action->className();
|
|
58
|
+
$path = "app/Nevela/Actions/{$d->name}/{$class}.php";
|
|
59
|
+
$asks = $action->fields === [] ? 'It asks for nothing, so $input is empty.' : 'What the person filled in, checked: '.implode(', ', array_keys($action->fields)).'.';
|
|
60
|
+
[$imports, $signature, $what] = $action->on === 'record'
|
|
61
|
+
? ["\nuse App\\Models\\{$d->name};\n", "{$d->name} \${$var}, array \$input", "on one {$this->lower($d->label)}"]
|
|
62
|
+
: ['', 'array $input', "on {$this->lower($d->pluralLabel)} as a whole"];
|
|
63
|
+
$permission = "{$d->table}.{$action->key()}";
|
|
64
|
+
|
|
65
|
+
return <<<PHP
|
|
66
|
+
<?php
|
|
67
|
+
|
|
68
|
+
namespace App\Nevela\Actions\\{$d->name};
|
|
69
|
+
{$imports}
|
|
70
|
+
/**
|
|
71
|
+
* {$action->label}, {$what}. Generated once — this file is yours.
|
|
72
|
+
*
|
|
73
|
+
* Nevela has done the rest by the time handle() runs: the route, the "{$permission}"
|
|
74
|
+
* permission, the check that the action applies, and the validation of what was asked
|
|
75
|
+
* for. It runs inside a database transaction, so throwing undoes what it changed, and
|
|
76
|
+
* the person is told it didn't work. What it returns is the message they see.
|
|
77
|
+
*/
|
|
78
|
+
class {$class}
|
|
79
|
+
{
|
|
80
|
+
/** @param array<string, mixed> \$input {$asks} */
|
|
81
|
+
public function handle({$signature}): ?string
|
|
82
|
+
{
|
|
83
|
+
throw new \LogicException('{$action->label} does nothing yet. Write it in {$path}.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
PHP;
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
/**
|
|
44
91
|
* The resources that point at this one, each with the field that does it and the name
|
|
45
92
|
* the relation gets here: a Category's Products are `products`. A second field from
|
|
@@ -90,6 +137,23 @@ final class ResourceGenerator
|
|
|
90
137
|
return Naming::snake(Naming::plural($name));
|
|
91
138
|
}
|
|
92
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Whether a resource's ids are integers. A relation's column, its validation and what
|
|
142
|
+
* the API sends for it all follow the resource it points at, not the one it is on.
|
|
143
|
+
*
|
|
144
|
+
* @param list<Descriptor> $all
|
|
145
|
+
*/
|
|
146
|
+
private function hasIntIds(string $name, Descriptor $d, array $all): bool
|
|
147
|
+
{
|
|
148
|
+
foreach ([$d, ...$all] as $candidate) {
|
|
149
|
+
if ($candidate->name === $name) {
|
|
150
|
+
return $candidate->idType === 'int';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
93
157
|
/**
|
|
94
158
|
* The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
|
|
95
159
|
* each with its loading skeleton. They only name the descriptor — Flare's components
|
|
@@ -226,7 +290,7 @@ final class ResourceGenerator
|
|
|
226
290
|
|
|
227
291
|
// What you type => the artisan command it runs.
|
|
228
292
|
// "update" is what upgrade was called before 0.3.0; it still works.
|
|
229
|
-
\$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash'];
|
|
293
|
+
\$nevela = ['resource', 'adopt', 'blog', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash', 'digest', 'disposable'];
|
|
230
294
|
\$artisan = ['migrate', 'tinker', 'test', 'serve'];
|
|
231
295
|
if (in_array(\$name, ['--version', '-v', '-V'], true)) {
|
|
232
296
|
\$name = 'version';
|
|
@@ -252,10 +316,13 @@ final class ResourceGenerator
|
|
|
252
316
|
php nevela status check versions, migrations, users and the dashboard
|
|
253
317
|
php nevela resource Product --fields="name:string, price:money" --seed
|
|
254
318
|
add a resource, create its table, fill it
|
|
319
|
+
php nevela adopt posts make a resource of a table you already have
|
|
320
|
+
php nevela blog give the site a blog: posts in the dashboard, /blog to read them
|
|
255
321
|
php nevela generate regenerate after editing a descriptor
|
|
256
322
|
php nevela seed Product fill a resource with records
|
|
257
323
|
php nevela user create someone who can sign in
|
|
258
324
|
php nevela trash remove deleted records whose time in the trash is up
|
|
325
|
+
php nevela disposable fetch the current list of throwaway email domains
|
|
259
326
|
php nevela upgrade bring this app to the latest Nevela
|
|
260
327
|
php nevela version which Nevela this is
|
|
261
328
|
|
|
@@ -303,6 +370,25 @@ final class ResourceGenerator
|
|
|
303
370
|
$body = $names === []
|
|
304
371
|
? 'export const resources = [] as const;'
|
|
305
372
|
: implode("\n", $imports)."\n\nexport { {$list} };\nexport const resources = [{$list}] as const;";
|
|
373
|
+
// What Flare's descriptor has no place for, by resource name: the actions a resource has.
|
|
374
|
+
$extras = [];
|
|
375
|
+
foreach ($all as $d) {
|
|
376
|
+
if ($d->actions !== []) {
|
|
377
|
+
$extras[$d->name]['actions'] = array_map(fn (Action $a) => ['permission' => "{$d->table}.{$a->key()}"] + $a->toArray(), $d->actions);
|
|
378
|
+
}
|
|
379
|
+
// The field its records are published by, and where on the site they are a blog.
|
|
380
|
+
if ($d->publish !== null) {
|
|
381
|
+
$extras[$d->name]['publish'] = $d->publish;
|
|
382
|
+
}
|
|
383
|
+
if ($d->blog !== null) {
|
|
384
|
+
$extras[$d->name]['blog'] = $d->blog;
|
|
385
|
+
}
|
|
386
|
+
// What its public form checks, and the address it has proved.
|
|
387
|
+
if ($d->public?->protect !== null || $d->public?->confirm !== null) {
|
|
388
|
+
$extras[$d->name]['form'] = array_filter(['protect' => $d->public->toArray()['create']['protect'] ?? null, 'confirm' => $d->public->confirm], fn ($v) => $v !== null);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
$body .= "\n\n/** What a resource has beyond its fields. Read through lib/extras.ts. */\nexport const extras: Record<string, unknown> = ".($extras === [] ? '{}' : json_encode($extras, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)).';';
|
|
306
392
|
$m = self::M;
|
|
307
393
|
$e = self::E;
|
|
308
394
|
|
|
@@ -387,7 +473,9 @@ final class ResourceGenerator
|
|
|
387
473
|
$lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
|
|
388
474
|
$lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
|
|
389
475
|
$lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
|
|
390
|
-
|
|
476
|
+
// An integer id is digits and nothing else: anything else in its place is no record's address.
|
|
477
|
+
$where = $d->idType === 'int' ? "->where(['{$d->routeParameter()}' => '[0-9]{1,18}'])" : '';
|
|
478
|
+
$lines[] = "Route::apiResource('{$d->slug}', {$controller}){$where};";
|
|
391
479
|
}
|
|
392
480
|
$body = implode("\n", $lines);
|
|
393
481
|
$m = self::M;
|
|
@@ -407,6 +495,69 @@ final class ResourceGenerator
|
|
|
407
495
|
PHP);
|
|
408
496
|
}
|
|
409
497
|
|
|
498
|
+
/**
|
|
499
|
+
* The routes anyone can call: only for resources with a "public" section, and only
|
|
500
|
+
* what each one's section opens. Loaded by Nevela under {prefix}/public with the api
|
|
501
|
+
* middleware and no sign-in.
|
|
502
|
+
*
|
|
503
|
+
* @param list<Descriptor> $all
|
|
504
|
+
*/
|
|
505
|
+
public function publicRoutes(array $all): GeneratedFile
|
|
506
|
+
{
|
|
507
|
+
usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->slug, $b->slug));
|
|
508
|
+
$controller = '\\Nevela\\Laravel\\Http\\PublicController::class';
|
|
509
|
+
$lines = [];
|
|
510
|
+
foreach ($all as $d) {
|
|
511
|
+
$public = $d->public;
|
|
512
|
+
if ($public === null) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
$more = implode('', array_map(fn (string $name) => ", '{$name}'", $public->middleware));
|
|
516
|
+
$for = "->defaults('resource', '{$d->name}')";
|
|
517
|
+
// The third part names the count. Without it Laravel keeps one per visitor for every
|
|
518
|
+
// throttled route there is, and reading a few pages would use up a form's allowance.
|
|
519
|
+
// (nevela.throttle is Laravel's throttle, counting the visitor and not the web app's server: see PublicThrottle.)
|
|
520
|
+
$throttle = fn (string $limit, string $what) => "'nevela.throttle:{$limit},{$d->slug}.{$what}:'";
|
|
521
|
+
if ($public->read !== null) {
|
|
522
|
+
$lines[] = "Route::get('{$d->slug}', [{$controller}, 'index']){$for}->middleware([{$throttle($public->readThrottle, 'read')}{$more}])->name('{$d->slug}.index');";
|
|
523
|
+
$lines[] = "Route::get('{$d->slug}/{id}', [{$controller}, 'show']){$for}->middleware([{$throttle($public->readThrottle, 'read')}{$more}])->name('{$d->slug}.show');";
|
|
524
|
+
}
|
|
525
|
+
if ($public->create !== null) {
|
|
526
|
+
$lines[] = "Route::post('{$d->slug}', [{$controller}, 'store']){$for}->middleware([{$throttle($public->createThrottle, 'create')}{$more}])->name('{$d->slug}.store');";
|
|
527
|
+
foreach ($public->create as $field) {
|
|
528
|
+
if ($d->fields[$field]->kind === 'file') {
|
|
529
|
+
$lines[] = "Route::put('_nevela/uploads/{$d->name}/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}');";
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// What a protected form asks for before it is shown, and after each attempt.
|
|
533
|
+
if ($public->protected()) {
|
|
534
|
+
$lines[] = "Route::get('_nevela/form/{$d->slug}', [{$controller}, 'form']){$for}->middleware([{$throttle(PublicAccess::READ_THROTTLE, 'form')}])->name('{$d->slug}.form');";
|
|
535
|
+
}
|
|
536
|
+
// The link that proves an address.
|
|
537
|
+
if ($public->confirm !== null) {
|
|
538
|
+
$lines[] = "Route::post('_nevela/confirm/{$d->slug}/{id}/{key}', [{$controller}, 'confirm']){$for}->middleware([{$throttle(PublicAccess::READ_THROTTLE, 'confirm')}])->name('{$d->slug}.confirm');";
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
$body = $lines === [] ? '// No resource has a "public" section, so nothing is open.' : implode("\n", $lines);
|
|
543
|
+
$m = self::M;
|
|
544
|
+
$e = self::E;
|
|
545
|
+
|
|
546
|
+
return new GeneratedFile(GeneratedFile::TARGET_API, 'routes/nevela-public.php', <<<PHP
|
|
547
|
+
<?php
|
|
548
|
+
|
|
549
|
+
use Illuminate\Support\Facades\Route;
|
|
550
|
+
|
|
551
|
+
// What anyone can call without signing in: {prefix}/public/… (default: /api/public).
|
|
552
|
+
// Written from each resource's "public" section by `php artisan nevela:generate`.
|
|
553
|
+
// A resource without one has no route here, and so nothing of it is open.
|
|
554
|
+
{$m}
|
|
555
|
+
{$body}
|
|
556
|
+
{$e}
|
|
557
|
+
|
|
558
|
+
PHP);
|
|
559
|
+
}
|
|
560
|
+
|
|
410
561
|
/** @param list<Descriptor> $all */
|
|
411
562
|
public function model(Descriptor $d, array $all = []): string
|
|
412
563
|
{
|
|
@@ -448,6 +599,48 @@ final class ResourceGenerator
|
|
|
448
599
|
}
|
|
449
600
|
}
|
|
450
601
|
$castBlock = $casts ? implode("\n", $casts) : '';
|
|
602
|
+
// Written with the model, outside its generated block: which records are public is the app's to say.
|
|
603
|
+
$published = $d->publish === null ? null : $d->fields[$d->publish]->column();
|
|
604
|
+
$scope = match (true) {
|
|
605
|
+
$d->public?->read === null => '',
|
|
606
|
+
// A resource with a publish date has said it already: the ones whose time has come.
|
|
607
|
+
$published !== null => <<<PHP
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Which {$this->lower($d->pluralLabel)} anyone may read, signed in or not: the published ones. The
|
|
612
|
+
* public list and the public page of one both go through here. A draft has no
|
|
613
|
+
* {$published}, and a scheduled one has a time still to come, which this lets
|
|
614
|
+
* through when it does, with nobody saving anything.
|
|
615
|
+
*/
|
|
616
|
+
public function scopePublicQuery(\Illuminate\Database\Eloquent\Builder \$query): void
|
|
617
|
+
{
|
|
618
|
+
\$query->whereNotNull('{$published}')->where('{$published}', '<=', now());
|
|
619
|
+
}
|
|
620
|
+
PHP,
|
|
621
|
+
default => <<<PHP
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Which {$this->lower($d->pluralLabel)} anyone may read, signed in or not. The public list and
|
|
626
|
+
* the public page of one both go through here. It lets nothing through until you
|
|
627
|
+
* say what may be: for instance
|
|
628
|
+
*
|
|
629
|
+
* \$query->whereNotNull('published_at')->where('published_at', '<=', now());
|
|
630
|
+
*/
|
|
631
|
+
public function scopePublicQuery(\Illuminate\Database\Eloquent\Builder \$query): void
|
|
632
|
+
{
|
|
633
|
+
\$query->whereRaw('1 = 0');
|
|
634
|
+
}
|
|
635
|
+
PHP,
|
|
636
|
+
};
|
|
637
|
+
// An integer id is Eloquent's own default: the database numbers the records.
|
|
638
|
+
[$import, $trait] = $d->idType === 'int' ? ['', ''] : ["use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;\n", " use HasUuids;\n\n"];
|
|
639
|
+
$confirmable = $d->public?->confirm === null ? '' : <<<'PHP'
|
|
640
|
+
|
|
641
|
+
// Its public form asks for an address to be proved: Subscriber::confirmed() is who has.
|
|
642
|
+
use \Nevela\Laravel\Concerns\Confirmable;
|
|
643
|
+
PHP;
|
|
451
644
|
$m = self::M;
|
|
452
645
|
$e = self::E;
|
|
453
646
|
|
|
@@ -456,18 +649,15 @@ final class ResourceGenerator
|
|
|
456
649
|
|
|
457
650
|
namespace App\Models;
|
|
458
651
|
|
|
459
|
-
use Illuminate\Database\Eloquent\
|
|
460
|
-
use Illuminate\Database\Eloquent\Model;
|
|
652
|
+
{$import}use Illuminate\Database\Eloquent\Model;
|
|
461
653
|
|
|
462
654
|
class {$d->name} extends Model
|
|
463
655
|
{
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
protected \$table = '{$d->table}';
|
|
656
|
+
{$trait} protected \$table = '{$d->table}';
|
|
467
657
|
|
|
468
658
|
{$m}
|
|
469
659
|
// Deleted records go to the trash, where they can be restored for a while.
|
|
470
|
-
use \Nevela\Laravel\Concerns\Trashable;
|
|
660
|
+
use \Nevela\Laravel\Concerns\Trashable;{$confirmable}
|
|
471
661
|
|
|
472
662
|
protected \$fillable = [{$fillable}];
|
|
473
663
|
|
|
@@ -479,7 +669,7 @@ final class ResourceGenerator
|
|
|
479
669
|
}{$relationBlock}
|
|
480
670
|
{$e}
|
|
481
671
|
|
|
482
|
-
// Your own relationships, scopes and accessors go here — outside the generated block.
|
|
672
|
+
// Your own relationships, scopes and accessors go here — outside the generated block.{$scope}
|
|
483
673
|
}
|
|
484
674
|
|
|
485
675
|
PHP;
|
|
@@ -493,7 +683,8 @@ final class ResourceGenerator
|
|
|
493
683
|
$c = $f->column();
|
|
494
684
|
if ($f->kind === 'belongsTo') {
|
|
495
685
|
// A required parent can't be deleted while it has children; an optional one lets go of them.
|
|
496
|
-
$
|
|
686
|
+
$foreign = $this->hasIntIds((string) $f->target, $d, $all) ? 'foreignId' : 'foreignUuid';
|
|
687
|
+
$columns[] = " \$table->{$foreign}('{$c}')"
|
|
497
688
|
.($f->required ? '' : '->nullable()')
|
|
498
689
|
.($f->unique ? '->unique()' : '->index()')
|
|
499
690
|
."->constrained('{$this->tableOf((string) $f->target, $d, $all)}')"
|
|
@@ -503,6 +694,8 @@ final class ResourceGenerator
|
|
|
503
694
|
}
|
|
504
695
|
$line = match (true) {
|
|
505
696
|
$f->kind === 'file' => "\$table->string('{$c}', 512)",
|
|
697
|
+
// More than a text column holds on MySQL, or HTML, whose tags count against it too.
|
|
698
|
+
$f->kind === 'text' && ($f->isRich() || $f->maxLength > 65535) => "\$table->longText('{$c}')",
|
|
506
699
|
$f->kind === 'text' => "\$table->text('{$c}')",
|
|
507
700
|
$f->kind === 'int' => "\$table->integer('{$c}')",
|
|
508
701
|
$f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
|
|
@@ -510,12 +703,17 @@ final class ResourceGenerator
|
|
|
510
703
|
$f->kind === 'boolean' => "\$table->boolean('{$c}')->default(false)",
|
|
511
704
|
$f->kind === 'date' => "\$table->date('{$c}')",
|
|
512
705
|
$f->kind === 'datetime' => "\$table->dateTime('{$c}')",
|
|
706
|
+
$f->maxLength !== null => "\$table->string('{$c}', {$f->maxLength})",
|
|
513
707
|
$f->format === 'url' => "\$table->string('{$c}', 2048)",
|
|
514
708
|
default => "\$table->string('{$c}')",
|
|
515
709
|
};
|
|
516
710
|
if (! $f->required) {
|
|
517
711
|
$line .= '->nullable()';
|
|
518
712
|
}
|
|
713
|
+
if ($f->default !== null && $f->kind !== 'text') {
|
|
714
|
+
// (A text column can't have one on MySQL; the request supplies it there.)
|
|
715
|
+
$line = str_replace('->default(false)', '', $line).'->default('.var_export($f->default, true).')';
|
|
716
|
+
}
|
|
519
717
|
if ($f->unique) {
|
|
520
718
|
$line .= '->unique()';
|
|
521
719
|
} elseif ($f->kind === 'enum') {
|
|
@@ -523,7 +721,12 @@ final class ResourceGenerator
|
|
|
523
721
|
}
|
|
524
722
|
$columns[] = " {$line};";
|
|
525
723
|
}
|
|
724
|
+
if ($d->public?->confirm !== null) {
|
|
725
|
+
$columns[] = ' // When the address was proved, by the link emailed to it. Empty until then.';
|
|
726
|
+
$columns[] = " \$table->timestamp('confirmed_at')->nullable()->index();";
|
|
727
|
+
}
|
|
526
728
|
$cols = implode("\n", $columns);
|
|
729
|
+
$id = $d->idType === 'int' ? '$table->id();' : "\$table->uuid('id')->primary();";
|
|
527
730
|
|
|
528
731
|
return <<<PHP
|
|
529
732
|
<?php
|
|
@@ -538,7 +741,7 @@ final class ResourceGenerator
|
|
|
538
741
|
public function up(): void
|
|
539
742
|
{
|
|
540
743
|
Schema::create('{$d->table}', function (Blueprint \$table) {
|
|
541
|
-
|
|
744
|
+
{$id}
|
|
542
745
|
{$cols}
|
|
543
746
|
\$table->timestamps();
|
|
544
747
|
// When a record was deleted. Until it is removed for good it sits in the trash.
|
|
@@ -596,6 +799,58 @@ final class ResourceGenerator
|
|
|
596
799
|
PHP;
|
|
597
800
|
}
|
|
598
801
|
|
|
802
|
+
/**
|
|
803
|
+
* For a table made before its form asked for addresses to be proved: the column that
|
|
804
|
+
* records it. The records already there are taken as proved. They were on the list
|
|
805
|
+
* before anyone was asked, and asking now would empty it.
|
|
806
|
+
*/
|
|
807
|
+
public function confirmMigration(Descriptor $d): string
|
|
808
|
+
{
|
|
809
|
+
return <<<PHP
|
|
810
|
+
<?php
|
|
811
|
+
|
|
812
|
+
use Illuminate\Database\Migrations\Migration;
|
|
813
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
814
|
+
use Illuminate\Support\Facades\DB;
|
|
815
|
+
use Illuminate\Support\Facades\Schema;
|
|
816
|
+
|
|
817
|
+
// {$d->pluralLabel} now prove their address: a record made through the public form is
|
|
818
|
+
// unconfirmed until the link emailed to it is followed. Generated once by Nevela.
|
|
819
|
+
return new class extends Migration
|
|
820
|
+
{
|
|
821
|
+
public function up(): void
|
|
822
|
+
{
|
|
823
|
+
if (Schema::hasTable('{$d->table}') && ! Schema::hasColumn('{$d->table}', 'confirmed_at')) {
|
|
824
|
+
Schema::table('{$d->table}', function (Blueprint \$table) {
|
|
825
|
+
\$table->timestamp('confirmed_at')->nullable()->index();
|
|
826
|
+
});
|
|
827
|
+
// Whoever is on the list already stays on it.
|
|
828
|
+
DB::table('{$d->table}')->update(['confirmed_at' => now()]);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
public function down(): void
|
|
833
|
+
{
|
|
834
|
+
if (Schema::hasColumn('{$d->table}', 'confirmed_at')) {
|
|
835
|
+
Schema::table('{$d->table}', function (Blueprint \$table) {
|
|
836
|
+
\$table->dropIndex(['confirmed_at']);
|
|
837
|
+
\$table->dropColumn('confirmed_at');
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
PHP;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** The file `confirmMigration()` goes in. Written once for a table, whatever its date. */
|
|
847
|
+
public function confirmMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
|
|
848
|
+
{
|
|
849
|
+
$timestamp ??= date('Y_m_d_His');
|
|
850
|
+
|
|
851
|
+
return new GeneratedFile(GeneratedFile::TARGET_API, "database/migrations/{$timestamp}_add_confirmation_to_{$d->table}_table.php", $this->confirmMigration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_add_confirmation_to_{$d->table}_table.php");
|
|
852
|
+
}
|
|
853
|
+
|
|
599
854
|
/** The file `trashMigration()` goes in. Not made again once one exists for the table, whatever its date. */
|
|
600
855
|
public function trashMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
|
|
601
856
|
{
|
|
@@ -617,6 +872,40 @@ final class ResourceGenerator
|
|
|
617
872
|
}
|
|
618
873
|
$ruleLines = implode("\n", $rules);
|
|
619
874
|
$columnMap = implode(', ', $columns);
|
|
875
|
+
$defaults = implode(', ', array_map(fn (Field $f) => "'{$f->name}' => ".var_export($f->default, true), array_filter($d->fields, fn (Field $f) => $f->default !== null)));
|
|
876
|
+
$defaultsBlock = $defaults === '' ? '' : <<<PHP
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
/** What a field is when a create leaves it out. */
|
|
880
|
+
protected function defaults(): array
|
|
881
|
+
{
|
|
882
|
+
return [{$defaults}];
|
|
883
|
+
}
|
|
884
|
+
PHP;
|
|
885
|
+
$made = implode(', ', array_map(
|
|
886
|
+
fn (Field $f) => "'{$f->name}' => ['from' => '{$f->from}', 'table' => '{$d->table}', 'column' => '{$f->column()}']",
|
|
887
|
+
array_filter($d->fields, fn (Field $f) => $f->from !== null),
|
|
888
|
+
));
|
|
889
|
+
$madeBlock = $made === '' ? '' : <<<PHP
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
/** Fields made from another when a create leaves them empty. They are never remade afterwards. */
|
|
893
|
+
protected function made(): array
|
|
894
|
+
{
|
|
895
|
+
return [{$made}];
|
|
896
|
+
}
|
|
897
|
+
PHP;
|
|
898
|
+
// Only where there is one: a resource without rich text has the request it always had.
|
|
899
|
+
$rich = implode(', ', array_map(fn (Field $f) => "'{$f->name}'", array_filter($d->fields, fn (Field $f) => $f->isRich())));
|
|
900
|
+
$richBlock = $rich === '' ? '' : <<<PHP
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
/** Fields that hold HTML. It is cleaned before it is checked, so the length is that of what is kept. */
|
|
904
|
+
protected function rich(): array
|
|
905
|
+
{
|
|
906
|
+
return [{$rich}];
|
|
907
|
+
}
|
|
908
|
+
PHP;
|
|
620
909
|
$m = self::M;
|
|
621
910
|
$e = self::E;
|
|
622
911
|
|
|
@@ -658,7 +947,7 @@ final class ResourceGenerator
|
|
|
658
947
|
protected function columns(): array
|
|
659
948
|
{
|
|
660
949
|
return [{$columnMap}];
|
|
661
|
-
}
|
|
950
|
+
}{$richBlock}{$defaultsBlock}{$madeBlock}
|
|
662
951
|
{$e}
|
|
663
952
|
}
|
|
664
953
|
|
|
@@ -671,17 +960,25 @@ final class ResourceGenerator
|
|
|
671
960
|
*/
|
|
672
961
|
private function rules(Descriptor $d, Field $f, array $all = []): array
|
|
673
962
|
{
|
|
674
|
-
|
|
963
|
+
// A slug that is made from another field may be left empty: the request fills it in.
|
|
964
|
+
$rules = [$f->required && $f->from === null ? "'required'" : "'nullable'"];
|
|
965
|
+
// With a default, leaving it out is saying "the usual". Sent, it is checked like any other.
|
|
966
|
+
if ($f->default !== null) {
|
|
967
|
+
array_unshift($rules, "'sometimes'");
|
|
968
|
+
}
|
|
969
|
+
// A length of the field's own takes the place of the usual one.
|
|
970
|
+
$max = fn (int $usual) => "'max:".($f->maxLength ?? $usual)."'";
|
|
675
971
|
$type = match (true) {
|
|
676
|
-
|
|
972
|
+
// An integer id is up to 18 digits, which is as many as always fit in the column.
|
|
973
|
+
$f->kind === 'belongsTo' => [...($this->hasIntIds((string) $f->target, $d, $all) ? ["'integer'", "'digits_between:1,18'"] : ["'uuid'"]), "Rule::exists('{$this->tableOf((string) $f->target, $d, $all)}', 'id')"],
|
|
677
974
|
$f->kind === 'file' => ["'string'", "'max:512'", "new \\Nevela\\Laravel\\Rules\\UploadKey('{$d->name}', '{$f->name}')"],
|
|
678
|
-
$f->format === 'email' => ["'email'",
|
|
679
|
-
$f->format === 'url' => ["'url'",
|
|
680
|
-
$f->format === 'tel' => ["'string'",
|
|
681
|
-
$f->format === 'slug' => ["'string'", "'alpha_dash'",
|
|
975
|
+
$f->format === 'email' => ["'email'", $max(255)],
|
|
976
|
+
$f->format === 'url' => ["'url'", $max(2048)],
|
|
977
|
+
$f->format === 'tel' => ["'string'", $max(32)],
|
|
978
|
+
$f->format === 'slug' => ["'string'", "'alpha_dash'", $max(255)],
|
|
682
979
|
$f->format === 'color' => ["'string'", "'regex:/^#[0-9a-fA-F]{6}$/'"],
|
|
683
|
-
$f->kind === 'string' => ["'string'",
|
|
684
|
-
$f->kind === 'text' => ["'string'",
|
|
980
|
+
$f->kind === 'string' => ["'string'", $max(255)],
|
|
981
|
+
$f->kind === 'text' => ["'string'", $max(65535)],
|
|
685
982
|
$f->kind === 'int' && $f->format === 'percent' => ["'integer'", "'between:0,100'"],
|
|
686
983
|
$f->kind === 'int' => ["'integer'"],
|
|
687
984
|
$f->kind === 'float' && $f->format === 'percent' => ["'numeric'", "'between:0,100'"],
|
|
@@ -699,22 +996,35 @@ final class ResourceGenerator
|
|
|
699
996
|
return $rules;
|
|
700
997
|
}
|
|
701
998
|
|
|
702
|
-
|
|
999
|
+
/** @param list<Descriptor> $all */
|
|
1000
|
+
public function resource(Descriptor $d, array $all = []): string
|
|
703
1001
|
{
|
|
704
1002
|
$lines = [];
|
|
705
1003
|
foreach ($d->fields as $f) {
|
|
706
|
-
$value = match (
|
|
707
|
-
'date' => "\$this->{$f->column()}?->format('Y-m-d')",
|
|
708
|
-
'datetime' => "\$this->{$f->column()}?->toJSON()",
|
|
1004
|
+
$value = match (true) {
|
|
1005
|
+
$f->kind === 'date' => "\$this->{$f->column()}?->format('Y-m-d')",
|
|
1006
|
+
$f->kind === 'datetime' => "\$this->{$f->column()}?->toJSON()",
|
|
1007
|
+
// Cleaned again on the way out: HTML can reach a table without passing a request.
|
|
1008
|
+
$f->isRich() => "\\Nevela\\Laravel\\Nevela::html(\$this->{$f->column()})",
|
|
1009
|
+
// An id is a string in the API whatever it is in the database.
|
|
1010
|
+
$f->kind === 'belongsTo' && $this->hasIntIds((string) $f->target, $d, $all) => "\$this->{$f->column()} === null ? null : (string) \$this->{$f->column()}",
|
|
709
1011
|
default => "\$this->{$f->column()}",
|
|
710
1012
|
};
|
|
711
1013
|
$lines[] = " '{$f->name}' => {$value},";
|
|
712
1014
|
if ($f->kind === 'file') {
|
|
713
1015
|
// The key is what a record stores; this is what a page needs to show it.
|
|
714
|
-
$lines[] =
|
|
1016
|
+
$lines[] = $f->private
|
|
1017
|
+
// A private file is fetched through its record, so its details say which record.
|
|
1018
|
+
? " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::privateFile(\$this->{$f->column()}, '{$d->slug}', \$this->getKey(), '{$f->name}'),"
|
|
1019
|
+
: " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
|
|
715
1020
|
}
|
|
716
1021
|
}
|
|
1022
|
+
if ($d->public?->confirm !== null) {
|
|
1023
|
+
// Not a field: when the address was proved, for the dashboard to say "Not confirmed" until it is.
|
|
1024
|
+
$lines[] = " 'confirmedAt' => \$this->confirmed_at?->toJSON(),";
|
|
1025
|
+
}
|
|
717
1026
|
$body = implode("\n", $lines);
|
|
1027
|
+
$id = $d->idType === 'int' ? '(string) $this->id' : '$this->id';
|
|
718
1028
|
$m = self::M;
|
|
719
1029
|
$e = self::E;
|
|
720
1030
|
|
|
@@ -734,7 +1044,7 @@ final class ResourceGenerator
|
|
|
734
1044
|
public function toArray(Request \$request): array
|
|
735
1045
|
{
|
|
736
1046
|
return [
|
|
737
|
-
'id' =>
|
|
1047
|
+
'id' => {$id},
|
|
738
1048
|
{$m}
|
|
739
1049
|
{$body}
|
|
740
1050
|
{$e}
|
|
@@ -949,16 +1259,30 @@ final class ResourceGenerator
|
|
|
949
1259
|
$fields = [];
|
|
950
1260
|
foreach ($d->fields as $f) {
|
|
951
1261
|
$options = array_filter([
|
|
952
|
-
|
|
1262
|
+
// (A slug made from another field is optional in the form: left empty, Laravel makes it.)
|
|
1263
|
+
'required' => $f->required && $f->from === null ? null : 'false',
|
|
953
1264
|
'unique' => $f->unique ? 'true' : null,
|
|
954
1265
|
'label' => $f->label ? $j($f->label) : null,
|
|
955
1266
|
'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
|
|
1267
|
+
'maxLength' => $f->maxLength !== null ? (string) $f->maxLength : null,
|
|
1268
|
+
'default' => $f->default !== null ? $j($f->default) : null,
|
|
956
1269
|
// What the migration does when the parent goes: an optional link is cleared.
|
|
957
1270
|
'onDelete' => $f->kind === 'belongsTo' && ! $f->required ? '"set null"' : null,
|
|
958
|
-
// A picture is worth a column in the table; other files are not.
|
|
959
|
-
'list' => $f->isImage() ? 'true' : null,
|
|
1271
|
+
// A picture is worth a column in the table; other files are not, and a private one has no thumbnail to show.
|
|
1272
|
+
'list' => $f->isImage() && ! $f->private ? 'true' : null,
|
|
960
1273
|
]);
|
|
961
|
-
$
|
|
1274
|
+
$parts = array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options);
|
|
1275
|
+
if ($f->private) {
|
|
1276
|
+
$parts[] = '...({ private: true } as {})';
|
|
1277
|
+
}
|
|
1278
|
+
if ($f->from !== null) {
|
|
1279
|
+
$parts[] = '...({ from: '.$j($f->from).' } as {})';
|
|
1280
|
+
}
|
|
1281
|
+
if ($f->isRich()) {
|
|
1282
|
+
// Flare has no word for it, so it is said in passing: the dashboard reads `rich` off the field.
|
|
1283
|
+
$parts[] = '...({ rich: true } as {})';
|
|
1284
|
+
}
|
|
1285
|
+
$opts = $parts ? '{ '.implode(', ', $parts).' }' : '';
|
|
962
1286
|
$call = match (true) {
|
|
963
1287
|
$f->kind === 'belongsTo' => "field.belongsTo({$j($f->target)}".($opts ? ", {$opts}" : '').')',
|
|
964
1288
|
$f->kind === 'file' => "field.file({$j($f->accept)}".($opts ? ", {$opts}" : '').')',
|
|
@@ -11,6 +11,7 @@ use Nevela\Laravel\Access\ClosedAccounts;
|
|
|
11
11
|
use Nevela\Laravel\Auth\Account;
|
|
12
12
|
use Nevela\Laravel\Auth\AuthMail;
|
|
13
13
|
use Nevela\Laravel\Http\Auth\Answers;
|
|
14
|
+
use Nevela\Laravel\Support\Key;
|
|
14
15
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -144,7 +145,7 @@ final class DeletedAccountsController
|
|
|
144
145
|
|
|
145
146
|
private function find(string $id): mixed
|
|
146
147
|
{
|
|
147
|
-
return ClosedAccounts::closed()->find($id) ?? throw new NotFoundHttpException('No such deleted account.');
|
|
148
|
+
return (Key::fits(Account::model(), $id) ? ClosedAccounts::closed()->find($id) : null) ?? throw new NotFoundHttpException('No such deleted account.');
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
/** An upgraded app keeps no deleted accounts until it has run its migrations. */
|
|
@@ -13,6 +13,7 @@ use Nevela\Laravel\Access\ClosedAccounts;
|
|
|
13
13
|
use Nevela\Laravel\Access\Permissions;
|
|
14
14
|
use Nevela\Laravel\Http\Auth\Answers;
|
|
15
15
|
use Nevela\Laravel\Models\Role;
|
|
16
|
+
use Nevela\Laravel\Support\Key;
|
|
16
17
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
17
18
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
18
19
|
|
|
@@ -124,7 +125,7 @@ final class RolesController
|
|
|
124
125
|
|
|
125
126
|
private function find(string $id): Role
|
|
126
127
|
{
|
|
127
|
-
return Role::query()->find($id) ?? throw new NotFoundHttpException('No such role.');
|
|
128
|
+
return (Key::fits(Role::class, $id) ? Role::query()->find($id) : null) ?? throw new NotFoundHttpException('No such role.');
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
private function users(Role $role): int
|
|
@@ -19,6 +19,7 @@ use Nevela\Laravel\Auth\AuthMail;
|
|
|
19
19
|
use Nevela\Laravel\Http\Auth\Answers;
|
|
20
20
|
use Nevela\Laravel\Media\Uploads;
|
|
21
21
|
use Nevela\Laravel\Models\Role;
|
|
22
|
+
use Nevela\Laravel\Support\Key;
|
|
22
23
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -248,7 +249,7 @@ final class UsersController
|
|
|
248
249
|
|
|
249
250
|
private function find(string $id): mixed
|
|
250
251
|
{
|
|
251
|
-
return ClosedAccounts::open()->find($id) ?? throw new NotFoundHttpException('No such user.');
|
|
252
|
+
return (Key::fits(Account::model(), $id) ? ClosedAccounts::open()->find($id) : null) ?? throw new NotFoundHttpException('No such user.');
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
/**
|