From 636c48f79f2308bb607391d2d369de79f0c21766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 24 Sep 2026 13:35:19 +0300 Subject: [PATCH 1/3] fix(backend): serve the notifications the client already polls The Flutter client ships with `notifications: true`, so its notifications screen and bell poll `api/v1/notifications` from the first sign-in, while the backend left `Features::notifications()` commented out and every poll was a 404. Enabling the feature alone was not enough. This app runs on integer keys, and the published notifications migration gave the table an auto-incrementing id, while Laravel's database channel always writes the notification's own UUID there: every `$user->notify()` failed at insert and the screen could only ever be empty. The table's key is now a UUID in both modes; the morph columns still follow `use_uuids`. The same fix goes into magic-starter-laravel's stub. An existing local database keeps the old table; `php artisan migrate:fresh` rebuilds it. Nothing could have written to it through the channel. --- backend/config/magic-starter.php | 2 +- ...6_24_100050_create_notifications_table.php | 4 +- .../tests/Feature/NotificationRoutesTest.php | 88 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 backend/tests/Feature/NotificationRoutesTest.php diff --git a/backend/config/magic-starter.php b/backend/config/magic-starter.php index 71d7c6f..392a90e 100644 --- a/backend/config/magic-starter.php +++ b/backend/config/magic-starter.php @@ -43,7 +43,7 @@ // \FlutterSdk\MagicStarter\Features::socialLogin(), // \FlutterSdk\MagicStarter\Features::newsletterSubscription(), // \FlutterSdk\MagicStarter\Features::extendedProfile(), - // \FlutterSdk\MagicStarter\Features::notifications(), + Features::notifications(), // \FlutterSdk\MagicStarter\Features::onesignal(), // \FlutterSdk\MagicStarter\Features::guestAuth(), // \FlutterSdk\MagicStarter\Features::phoneOtp(), diff --git a/backend/database/migrations/2026_06_24_100050_create_notifications_table.php b/backend/database/migrations/2026_06_24_100050_create_notifications_table.php index 9e0a0f2..4bdea30 100644 --- a/backend/database/migrations/2026_06_24_100050_create_notifications_table.php +++ b/backend/database/migrations/2026_06_24_100050_create_notifications_table.php @@ -14,7 +14,9 @@ public function up(): void { if (! Schema::hasTable('notifications')) { Schema::create('notifications', function (Blueprint $table) { - MigrationHelper::primaryKey($table); + // Always a UUID: Laravel's database channel writes the notification's own + // UUID as the id in either `use_uuids` mode. Only the morph key follows it. + $table->uuid('id')->primary(); $table->string('type'); MigrationHelper::morphColumns($table, 'notifiable'); $table->text('data'); diff --git a/backend/tests/Feature/NotificationRoutesTest.php b/backend/tests/Feature/NotificationRoutesTest.php new file mode 100644 index 0000000..847dc2c --- /dev/null +++ b/backend/tests/Feature/NotificationRoutesTest.php @@ -0,0 +1,88 @@ +create()); + + $this->getJson('/api/v1/notifications')->assertOk(); + } + + public function test_a_database_notification_reaches_the_list(): void + { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $user->notify(new DatabaseOnlyNotification); + + $this->getJson('/api/v1/notifications') + ->assertOk() + ->assertJsonPath('data.0.data.title', 'Deploy finished'); + $this->getJson('/api/v1/notifications/unread-count') + ->assertOk() + ->assertJsonPath('data.count', 1); + } + + public function test_the_unread_count_answers_the_signed_in_user(): void + { + Sanctum::actingAs(User::factory()->create()); + + $this->getJson('/api/v1/notifications/unread-count')->assertOk(); + } + + public function test_the_notification_preferences_answer_the_signed_in_user(): void + { + Sanctum::actingAs(User::factory()->create()); + + $this->getJson('/api/v1/notification-preferences')->assertOk(); + } + + public function test_the_notification_list_refuses_a_guest(): void + { + $this->getJson('/api/v1/notifications')->assertUnauthorized(); + } +} + +/** + * A notification that goes through Laravel's `database` channel only, which writes the + * row with a UUID `id` whatever key type the application's own models use. + */ +class DatabaseOnlyNotification extends Notification +{ + /** + * @return array + */ + public function via(object $notifiable): array + { + return [ + 'database', + ]; + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'Deploy finished', + 'body' => 'Production is on the new build.', + ]; + } +} From df6c897e9db083265ee87231bf76d4b8be4a587e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 24 Sep 2026 13:44:10 +0300 Subject: [PATCH 2/3] fix(backend): repair a notifications table already migrated The fixed create migration only reaches a fresh database; its hasTable guard leaves a table an earlier migrate built with an auto-incrementing id, which the database channel still cannot write to. The rekey migration, copied by hand from magic-starter-laravel's unreleased stub (this app resolves 0.0.10, which does not ship it), rebuilds that table with a UUID key and carries any rows over; on a correct table it does nothing, and a run that stops part-way can be re-run. Against a copy of a real local database it turned the integer table into the UUID one with both indexes, and a notify() then listed over the API. add_sms_registered_at_to_users_table is the third migration the installer publishes with the notifications feature; it was never copied here, so the first OneSignal SMS registration would have failed on an unknown column. --- backend/config/magic-starter.php | 7 +- ...0_add_sms_registered_at_to_users_table.php | 32 +++ ...0020_rekey_notifications_table_by_uuid.php | 211 ++++++++++++++++++ 3 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 backend/database/migrations/2026_09_24_000010_add_sms_registered_at_to_users_table.php create mode 100644 backend/database/migrations/2026_09_24_000020_rekey_notifications_table_by_uuid.php diff --git a/backend/config/magic-starter.php b/backend/config/magic-starter.php index 392a90e..c0ed9a2 100644 --- a/backend/config/magic-starter.php +++ b/backend/config/magic-starter.php @@ -13,9 +13,10 @@ |-------------------------------------------------------------------------- | | Determines whether the package uses UUID primary keys or standard - | auto-incrementing integer IDs. When true, all package migrations - | use uuid() columns and foreignUuid() references. When false, - | standard id() and foreignId() are used instead. + | auto-incrementing integer IDs. When true, package migrations use + | uuid() columns and foreignUuid() references. When false, standard + | id() and foreignId() are used instead. The notifications table keeps + | a uuid() id either way, because Laravel's database channel writes one. | | This is set automatically during installation based on your | existing database schema, but can be changed manually. diff --git a/backend/database/migrations/2026_09_24_000010_add_sms_registered_at_to_users_table.php b/backend/database/migrations/2026_09_24_000010_add_sms_registered_at_to_users_table.php new file mode 100644 index 0000000..567adf1 --- /dev/null +++ b/backend/database/migrations/2026_09_24_000010_add_sms_registered_at_to_users_table.php @@ -0,0 +1,32 @@ +timestamp('sms_registered_at')->nullable(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('users', 'sms_registered_at')) { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('sms_registered_at'); + }); + } + } +}; diff --git a/backend/database/migrations/2026_09_24_000020_rekey_notifications_table_by_uuid.php b/backend/database/migrations/2026_09_24_000020_rekey_notifications_table_by_uuid.php new file mode 100644 index 0000000..35457c1 --- /dev/null +++ b/backend/database/migrations/2026_09_24_000020_rekey_notifications_table_by_uuid.php @@ -0,0 +1,211 @@ + + */ + private const COLUMNS = [ + 'id', + 'type', + 'notifiable_type', + 'notifiable_id', + 'data', + 'read_at', + 'created_at', + 'updated_at', + ]; + + private const SCRATCH = 'notifications_rekeyed'; + + /** + * Run the migrations. + */ + public function up(): void + { + // 1. Finish a run that stopped between dropping the old table and + // renaming the new one: the rows are all in the scratch table. + if (! Schema::hasTable('notifications') && Schema::hasTable(self::SCRATCH)) { + $this->promoteScratchTable(); + } + + if (! Schema::hasTable('notifications')) { + return; + } + + // 2. Rebuild only the table the old stub built. + if ($this->keyedByAutoIncrement()) { + $this->refuseUnknownColumns(); + $this->rebuild(); + } + + // 3. Also reached by a run that stopped before its indexes landed, whose + // table no longer looks like it needs rebuilding. + $this->ensureIndexes(); + } + + /** + * Reverse the migrations. + * + * Deliberately empty: restoring an auto-incrementing id would bring back a + * table the database channel cannot write to. + */ + public function down(): void {} + + /** + * Copy the table into a UUID-keyed one and swap it in. + */ + private function rebuild(): void + { + // 1. A scratch table beside the old one is left over from a run that + // failed while copying; the old table still holds every row. + Schema::dropIfExists(self::SCRATCH); + + Schema::create(self::SCRATCH, function (Blueprint $table): void { + $table->uuid('id')->primary(); + $table->string('type'); + $table->string('notifiable_type'); + MigrationHelper::usesUuids() + ? $table->uuid('notifiable_id') + : $table->unsignedBigInteger('notifiable_id'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + + // 2. Carry every row over under a fresh UUID. Rows reach this table only + // when a non-strict MySQL coerced the channel's UUID into a number, or + // when something created one through the model without an id; neither + // id identifies the notification anywhere a client can still use. + DB::table('notifications') + ->orderBy('id') + ->chunk(500, function ($rows): void { + DB::table(self::SCRATCH)->insert( + $rows->map(fn (object $row): array => [ + 'id' => (string) Str::uuid(), + 'type' => $row->type, + 'notifiable_type' => $row->notifiable_type, + 'notifiable_id' => $row->notifiable_id, + 'data' => $row->data, + 'read_at' => $row->read_at, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ])->all(), + ); + }); + + // 3. Swap the tables. + Schema::drop('notifications'); + $this->promoteScratchTable(); + } + + /** + * Rename the scratch table to its final name, primary key included. + */ + private function promoteScratchTable(): void + { + Schema::rename(self::SCRATCH, 'notifications'); + + // PostgreSQL names a primary key after the table it was created on and a + // rename keeps it, which would leave `dropPrimary()` looking for + // `notifications_pkey` on a repaired install and finding nothing. Read + // rather than assumed, since the scratch table may not be ours, and read + // through the schema builder so a table prefix and the search path are + // applied the same way `Schema::rename` applied them. + if (DB::getDriverName() !== 'pgsql') { + return; + } + + $current = collect(Schema::getIndexes('notifications'))->firstWhere('primary', true)['name'] ?? null; + $expected = DB::getTablePrefix().'notifications_pkey'; + + if ($current === null || $current === $expected) { + return; + } + + // Renaming the index renames the constraint that owns it. + Schema::table('notifications', fn (Blueprint $table) => $table->renameIndex($current, $expected)); + } + + /** + * Add whichever of the create stub's two indexes is missing, under its name. + */ + private function ensureIndexes(): void + { + $indexes = [ + [ + 'notifiable_type', + 'notifiable_id', + ], + [ + 'notifiable_type', + 'notifiable_id', + 'read_at', + ], + ]; + + foreach ($indexes as $columns) { + if (Schema::hasIndex('notifications', $columns)) { + continue; + } + + Schema::table('notifications', fn (Blueprint $table) => $table->index($columns)); + } + } + + /** + * Stop before a rebuild that would drop a column the application added. + * + * @throws RuntimeException When the table carries a column the rebuild does not copy. + */ + private function refuseUnknownColumns(): void + { + $unknown = array_values(array_diff(Schema::getColumnListing('notifications'), self::COLUMNS)); + + if ($unknown === []) { + return; + } + + throw new RuntimeException(sprintf( + 'The notifications table has an auto-incrementing id, which Laravel\'s database channel cannot ' + .'write to, and carries columns this migration would drop by rebuilding it: %s. Change its id ' + .'to a UUID primary key yourself, then run the migrations again.', + implode(', ', $unknown), + )); + } + + /** + * Whether the table's id is the auto-incrementing integer the old stub built. + */ + private function keyedByAutoIncrement(): bool + { + $id = collect(Schema::getColumns('notifications'))->firstWhere('name', 'id'); + + return (bool) ($id['auto_increment'] ?? false); + } +}; From e02e0e168fffba86be6882d402427c8f2fec0157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 24 Sep 2026 14:00:11 +0300 Subject: [PATCH 3/3] test(backend): prove the copied rekey migration rebuilds the table The rekey is a hand-copied stub that drops and rebuilds a table, and under RefreshDatabase the create migration already builds a UUID table, so CI never ran its rebuild path. The test puts back the integer-keyed shape, inserts a row, runs up(), and checks the row survives under a UUID with both indexes and that the database channel writes again; forcing the auto-increment check to false turns it red. A second test pins the sms_registered_at column. DatabaseOnlyNotification moves to its own file, since a second test now uses it and PSR-4 cannot find a class declared inside another test's file. --- .../Feature/DatabaseOnlyNotification.php | 33 ++++++++ .../tests/Feature/NotificationRoutesTest.php | 29 ------- .../RekeyNotificationsMigrationTest.php | 78 +++++++++++++++++++ 3 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 backend/tests/Feature/DatabaseOnlyNotification.php create mode 100644 backend/tests/Feature/RekeyNotificationsMigrationTest.php diff --git a/backend/tests/Feature/DatabaseOnlyNotification.php b/backend/tests/Feature/DatabaseOnlyNotification.php new file mode 100644 index 0000000..29853e1 --- /dev/null +++ b/backend/tests/Feature/DatabaseOnlyNotification.php @@ -0,0 +1,33 @@ + + */ + public function via(object $notifiable): array + { + return [ + 'database', + ]; + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'Deploy finished', + 'body' => 'Production is on the new build.', + ]; + } +} diff --git a/backend/tests/Feature/NotificationRoutesTest.php b/backend/tests/Feature/NotificationRoutesTest.php index 847dc2c..5e92029 100644 --- a/backend/tests/Feature/NotificationRoutesTest.php +++ b/backend/tests/Feature/NotificationRoutesTest.php @@ -4,7 +4,6 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Notifications\Notification; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -58,31 +57,3 @@ public function test_the_notification_list_refuses_a_guest(): void $this->getJson('/api/v1/notifications')->assertUnauthorized(); } } - -/** - * A notification that goes through Laravel's `database` channel only, which writes the - * row with a UUID `id` whatever key type the application's own models use. - */ -class DatabaseOnlyNotification extends Notification -{ - /** - * @return array - */ - public function via(object $notifiable): array - { - return [ - 'database', - ]; - } - - /** - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'title' => 'Deploy finished', - 'body' => 'Production is on the new build.', - ]; - } -} diff --git a/backend/tests/Feature/RekeyNotificationsMigrationTest.php b/backend/tests/Feature/RekeyNotificationsMigrationTest.php new file mode 100644 index 0000000..061be5c --- /dev/null +++ b/backend/tests/Feature/RekeyNotificationsMigrationTest.php @@ -0,0 +1,78 @@ +create(); + Schema::drop('notifications'); + Schema::create('notifications', function (Blueprint $table): void { + $table->id(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index([ + 'notifiable_type', + 'notifiable_id', + 'read_at', + ]); + }); + DB::table('notifications')->insert([ + 'id' => 7, + 'type' => DatabaseOnlyNotification::class, + 'notifiable_type' => $user->getMorphClass(), + 'notifiable_id' => $user->getKey(), + 'data' => json_encode([ + 'title' => 'Carried over', + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_09_24_000020_rekey_notifications_table_by_uuid.php'); + $migration->up(); + + // 2. The row survived under a UUID, and both lookup indexes are back. + $carried = DatabaseNotification::query()->sole(); + $this->assertTrue(Str::isUuid($carried->id)); + $this->assertSame('Carried over', $carried->data['title']); + $this->assertTrue(Schema::hasIndex('notifications', 'notifications_notifiable_type_notifiable_id_index')); + $this->assertTrue( + Schema::hasIndex('notifications', 'notifications_notifiable_type_notifiable_id_read_at_index'), + ); + + // 3. The database channel can write to it again. + $sent = new DatabaseOnlyNotification; + $sent->id = (string) Str::uuid(); + $user->notify($sent); + $this->assertTrue(DatabaseNotification::query()->whereKey($sent->id)->exists()); + } + + public function test_the_users_table_carries_the_column_onesignal_sms_registration_writes(): void + { + $this->assertTrue(Schema::hasColumn('users', 'sms_registered_at')); + } +}