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.
Files changed (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. 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
- 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"),
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
- $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
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\Concerns\HasUuids;
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
- use HasUuids;
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
- $columns[] = " \$table->foreignUuid('{$c}')"
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
- \$table->uuid('id')->primary();
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
- $rules = [$f->required ? "'required'" : "'nullable'"];
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
- $f->kind === 'belongsTo' => ["'uuid'", "Rule::exists('{$this->tableOf((string) $f->target, $d, $all)}', 'id')"],
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'", "'max:255'"],
679
- $f->format === 'url' => ["'url'", "'max:2048'"],
680
- $f->format === 'tel' => ["'string'", "'max:32'"],
681
- $f->format === 'slug' => ["'string'", "'alpha_dash'", "'max:255'"],
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'", "'max:255'"],
684
- $f->kind === 'text' => ["'string'", "'max:65535'"],
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
- public function resource(Descriptor $d): string
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 ($f->kind) {
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[] = " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
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' => \$this->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
- 'required' => $f->required ? null : 'false',
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
- $opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
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
  /**