diff --git a/backend/config/magic-starter.php b/backend/config/magic-starter.php index 71d7c6f..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. @@ -43,7 +44,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/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); + } +}; 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 new file mode 100644 index 0000000..5e92029 --- /dev/null +++ b/backend/tests/Feature/NotificationRoutesTest.php @@ -0,0 +1,59 @@ +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(); + } +} 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')); + } +}