Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions backend/config/magic-starter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'sms_registered_at')) {
$table->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');
});
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?php

use FlutterSdk\MagicStarter\Support\MigrationHelper;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;

/**
* Rebuilds a notifications table that an integer-key install created with an
* auto-incrementing id.
*
* Laravel's database channel writes the notification's own UUID as the id, so
* that table refused every delivery (SQLite, PostgreSQL and strict-mode MySQL
* reject the value). The create stub is fixed for fresh installs, but its
* `hasTable` guard means it never touches a table that already exists; this is
* the migration that does. On a table whose id is already a UUID it only adds
* whichever of the create stub's two lookup indexes is missing, which is also
* what finishes a run that stopped before its indexes landed.
*
* MySQL and SQLite run a migration outside a transaction, so every step is
* written to be re-run after a failure part-way through.
*/
return new class extends Migration
{
/**
* The columns the rebuild carries over. A table with any other column is
* refused rather than rebuilt, because the rebuild would drop it.
*
* @var list<string>
*/
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);
}
};
33 changes: 33 additions & 0 deletions backend/tests/Feature/DatabaseOnlyNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Tests\Feature;

use Illuminate\Notifications\Notification;

/**
* 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<int, string>
*/
public function via(object $notifiable): array
{
return [
'database',
];
}

/**
* @return array<string, string>
*/
public function toArray(object $notifiable): array
{
return [
'title' => 'Deploy finished',
'body' => 'Production is on the new build.',
];
}
}
59 changes: 59 additions & 0 deletions backend/tests/Feature/NotificationRoutesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

/**
* The Flutter client ships with `notifications: true` in `lib/config/magic_starter.dart`,
* so its notifications screen and bell poll these routes from the first sign-in. They
* exist only while the backend enables the same feature; with it off, every poll is a 404.
*/
class NotificationRoutesTest extends TestCase
{
use RefreshDatabase;

public function test_the_notification_list_answers_the_signed_in_user(): void
{
Sanctum::actingAs(User::factory()->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();
}
}
Loading
Loading