# Reservation Reminder Notifications Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a scheduled notification system that sends three reservation reminders: check-in reminder 1 day before check-in, checkout reminder 1 day before checkout, and evacuate reminder 2 hours before checkout.

**Architecture:** A `scheduled_notifications` table stores future reminders. A scheduler command runs every minute to dispatch due notifications via a queue job. A single Laravel Notification class delivers content to the existing in-app notifications table. The `ReservationService` recalculates scheduled rows whenever a reservation is created or updated.

**Tech Stack:** Laravel 11, Pest PHP, database queue, Eloquent.

---

## File Map

| File | Responsibility |
|---|---|
| `database/migrations/2026_06_19_203455_create_scheduled_notifications_table.php` | Stores future reminder records |
| `app/Models/ScheduledNotification.php` | Eloquent model + scopes |
| `app/Notifications/ReservationReminder.php` | Content/template for all three reminder types |
| `app/Jobs/DispatchScheduledNotification.php` | Sends one scheduled row via queue |
| `app/Console/Commands/SendDueScheduledNotifications.php` | Polls due rows and dispatches jobs |
| `routes/console.php` | Registers the scheduler to run every minute |
| `app/Services/ReservationReminderScheduler.php` | Creates/cancels scheduled rows for a reservation |
| `app/Services/ReservationService.php` | Calls scheduler after create/update |
| `tests/Feature/ScheduledNotificationTest.php` | Feature tests |

---

## Assumptions

- Default channel is the existing in-app `Notification` model (`user_id`, title, body, type).
- Only registered `Customer` records receive in-app notifications (via `customer->user`). `PendingCustomer` rows will be created when scheduling but skipped during dispatch until a channel is available.
- Reminder send times:
  - `check_in_reminder`: 1 day before `check_in_date` at 09:00 app timezone.
  - `checkout_reminder`: 1 day before `check_out_date` at 09:00 app timezone.
  - `evacuate_reminder`: `check_out_date` + building `check_out_time` - 2 hours.
- A reservation qualifies for reminders only when status is `confirmed` or `checked_in`. The evacuate reminder only sends when status is `checked_in`.

---

## Task 1: Create ScheduledNotification migration and model

**Files:**
- Create: `database/migrations/2026_06_19_203455_create_scheduled_notifications_table.php`
- Create: `app/Models/ScheduledNotification.php`

- [ ] **Step 1: Write migration**

```php
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('scheduled_notifications', function (Blueprint $table) {
            $table->id();
            $table->morphs('notifiable');
            $table->foreignId('reservation_id')->constrained('reservations')->cascadeOnDelete();
            $table->string('type'); // check_in_reminder | checkout_reminder | evacuate_reminder
            $table->timestamp('send_at');
            $table->timestamp('sent_at')->nullable();
            $table->timestamp('cancelled_at')->nullable();
            $table->json('payload')->nullable();
            $table->timestamps();

            $table->index(['send_at', 'sent_at', 'cancelled_at']);
            $table->index(['reservation_id', 'cancelled_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('scheduled_notifications');
    }
};
```

- [ ] **Step 2: Write model**

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Builder;

class ScheduledNotification extends Model
{
    use HasFactory;

    protected $fillable = [
        'notifiable_type',
        'notifiable_id',
        'reservation_id',
        'type',
        'send_at',
        'sent_at',
        'cancelled_at',
        'payload',
    ];

    protected function casts(): array
    {
        return [
            'send_at' => 'datetime',
            'sent_at' => 'datetime',
            'cancelled_at' => 'datetime',
            'payload' => 'array',
        ];
    }

    public function notifiable(): MorphTo
    {
        return $this->morphTo();
    }

    public function reservation(): BelongsTo
    {
        return $this->belongsTo(Reservation::class);
    }

    public function scopeDue(Builder $query): Builder
    {
        return $query->where('send_at', '<=', now())
            ->whereNull('sent_at')
            ->whereNull('cancelled_at');
    }

    public function scopePendingForReservation(Builder $query, int $reservationId): Builder
    {
        return $query->where('reservation_id', $reservationId)
            ->whereNull('sent_at')
            ->whereNull('cancelled_at');
    }

    public function markSent(): void
    {
        $this->update(['sent_at' => now()]);
    }

    public function cancel(): void
    {
        $this->update(['cancelled_at' => now()]);
    }
}
```

- [ ] **Step 3: Run migration**

```bash
php artisan migrate
```

Expected: migration completes without errors.

- [ ] **Step 4: Commit**

```bash
git add database/migrations/2026_06_19_203455_create_scheduled_notifications_table.php app/Models/ScheduledNotification.php
git commit -m "feat: add ScheduledNotification model and migration"
```

---

## Task 2: Create ReservationReminder notification class

**Files:**
- Create: `app/Notifications/ReservationReminder.php`

- [ ] **Step 1: Write notification class**

```php
<?php

namespace App\Notifications;

use App\Models\Reservation;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;

class ReservationReminder extends Notification
{
    use Queueable;

    public function __construct(
        public string $type,
        public Reservation $reservation,
        public array $payload = []
    ) {}

    public function via(object $notifiable): array
    {
        return ['database'];
    }

    public function toDatabase(object $notifiable): array
    {
        return match ($this->type) {
            'check_in_reminder' => [
                'title' => 'Check-in Reminder',
                'body' => "Your stay at {$this->buildingName()} starts tomorrow. Check-in time is {$this->checkInTime()}.",
                'type' => self::class,
            ],
            'checkout_reminder' => [
                'title' => 'Checkout Reminder',
                'body' => "Your stay at {$this->buildingName()} ends tomorrow. Checkout time is {$this->checkOutTime()}.",
                'type' => self::class,
            ],
            'evacuate_reminder' => [
                'title' => 'Evacuate Reminder',
                'body' => "Please prepare to leave {$this->buildingName()}. Checkout is at {$this->checkOutTime()} (in 2 hours).",
                'type' => self::class,
            ],
            default => [
                'title' => 'Reservation Reminder',
                'body' => 'You have an upcoming reservation event.',
                'type' => self::class,
            ],
        };
    }

    private function buildingName(): string
    {
        return $this->reservation->unit->building->name ?? 'your building';
    }

    private function checkInTime(): string
    {
        return $this->reservation->unit->building->check_in_time ?? '14:00';
    }

    private function checkOutTime(): string
    {
        return $this->reservation->unit->building->check_out_time ?? '12:00';
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add app/Notifications/ReservationReminder.php
git commit -m "feat: add ReservationReminder notification class"
```

---

## Task 3: Create DispatchScheduledNotification job

**Files:**
- Create: `app/Jobs/DispatchScheduledNotification.php`

- [ ] **Step 1: Write job**

```php
<?php

namespace App\Jobs;

use App\Models\Customer;
use App\Models\ScheduledNotification;
use App\Notifications\ReservationReminder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class DispatchScheduledNotification implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public ScheduledNotification $scheduledNotification) {}

    public function handle(): void
    {
        $this->scheduledNotification->refresh();

        if ($this->scheduledNotification->sent_at !== null || $this->scheduledNotification->cancelled_at !== null) {
            return;
        }

        $notifiable = $this->scheduledNotification->notifiable;
        $reservation = $this->scheduledNotification->reservation;

        if (! $reservation || in_array($reservation->status, ['canceled', 'checked_out'], true)) {
            $this->scheduledNotification->cancel();
            return;
        }

        if ($notifiable instanceof Customer && $notifiable->user) {
            $notifiable->user->notify(new ReservationReminder(
                $this->scheduledNotification->type,
                $reservation,
                $this->scheduledNotification->payload ?? []
            ));
            $this->scheduledNotification->markSent();
            return;
        }

        Log::info('Scheduled notification skipped: no delivery route', [
            'scheduled_notification_id' => $this->scheduledNotification->id,
            'notifiable_type' => $this->scheduledNotification->notifiable_type,
        ]);

        $this->scheduledNotification->cancel();
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add app/Jobs/DispatchScheduledNotification.php
git commit -m "feat: add DispatchScheduledNotification job"
```

---

## Task 4: Create SendDueScheduledNotifications command

**Files:**
- Create: `app/Console/Commands/SendDueScheduledNotifications.php`

- [ ] **Step 1: Write command**

```php
<?php

namespace App\Console\Commands;

use App\Jobs\DispatchScheduledNotification;
use App\Models\ScheduledNotification;
use Illuminate\Console\Command;

class SendDueScheduledNotifications extends Command
{
    protected $signature = 'notifications:send-due';

    protected $description = 'Dispatch all due scheduled notifications';

    public function handle(): int
    {
        $count = 0;

        ScheduledNotification::due()->cursor()->each(function (ScheduledNotification $notification) use (&$count) {
            DispatchScheduledNotification::dispatch($notification);
            $count++;
        });

        $this->info("Dispatched {$count} scheduled notification(s).");

        return self::SUCCESS;
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add app/Console/Commands/SendDueScheduledNotifications.php
git commit -m "feat: add notifications:send-due command"
```

---

## Task 5: Register the scheduler

**Files:**
- Modify: `routes/console.php`

- [ ] **Step 1: Add schedule**

```php
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::command('notifications:send-due')->everyMinute();
```

- [ ] **Step 2: Commit**

```bash
git add routes/console.php
git commit -m "feat: schedule notifications:send-due every minute"
```

---

## Task 6: Create ReservationReminderScheduler service

**Files:**
- Create: `app/Services/ReservationReminderScheduler.php`

- [ ] **Step 1: Write scheduler service**

```php
<?php

namespace App\Services;

use App\Models\Customer;
use App\Models\PendingCustomer;
use App\Models\Reservation;
use App\Models\ScheduledNotification;
use Carbon\Carbon;

class ReservationReminderScheduler
{
    public function scheduleFor(Reservation $reservation): void
    {
        $this->cancelPendingFor($reservation);

        if (in_array($reservation->status, ['canceled', 'checked_out'], true)) {
            return;
        }

        $building = $reservation->unit?->building;

        if (! $building) {
            return;
        }

        $customer = $reservation->customer;

        if (! $customer instanceof Customer && ! $customer instanceof PendingCustomer) {
            return;
        }

        if (in_array($reservation->status, ['confirmed', 'checked_in'], true)) {
            $this->createReminder(
                $reservation,
                $customer,
                'check_in_reminder',
                $this->checkInReminderAt($reservation)
            );

            $this->createReminder(
                $reservation,
                $customer,
                'checkout_reminder',
                $this->checkoutReminderAt($reservation)
            );
        }

        if ($reservation->status === 'checked_in') {
            $this->createReminder(
                $reservation,
                $customer,
                'evacuate_reminder',
                $this->evacuateReminderAt($reservation, $building->check_out_time)
            );
        }
    }

    public function cancelPendingFor(Reservation $reservation): void
    {
        ScheduledNotification::pendingForReservation($reservation->id)->get()->each->cancel();
    }

    private function createReminder(
        Reservation $reservation,
        Customer|PendingCustomer $customer,
        string $type,
        Carbon $sendAt
    ): void {
        if ($sendAt->isPast()) {
            return;
        }

        ScheduledNotification::create([
            'notifiable_type' => $customer->getMorphClass(),
            'notifiable_id' => $customer->getKey(),
            'reservation_id' => $reservation->id,
            'type' => $type,
            'send_at' => $sendAt,
            'payload' => [
                'building_name' => $reservation->unit->building->name,
                'check_in_time' => $reservation->unit->building->check_in_time,
                'check_out_time' => $reservation->unit->building->check_out_time,
            ],
        ]);
    }

    private function checkInReminderAt(Reservation $reservation): Carbon
    {
        return $reservation->check_in_date->copy()->subDay()->setTime(9, 0);
    }

    private function checkoutReminderAt(Reservation $reservation): Carbon
    {
        return $reservation->check_out_date->copy()->subDay()->setTime(9, 0);
    }

    private function evacuateReminderAt(Reservation $reservation, string $checkOutTime): Carbon
    {
        $time = Carbon::createFromFormat('H:i:s', $checkOutTime)
            ?? Carbon::createFromFormat('H:i', $checkOutTime)
            ?? Carbon::parse($checkOutTime);

        return $reservation->check_out_date->copy()
            ->setTime($time->hour, $time->minute, $time->second)
            ->subHours(2);
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add app/Services/ReservationReminderScheduler.php
git commit -m "feat: add ReservationReminderScheduler service"
```

---

## Task 7: Wire scheduler into ReservationService

**Files:**
- Modify: `app/Services/ReservationService.php`

- [ ] **Step 1: Import scheduler and call after create**

Add at the top:

```php
use App\Services\ReservationReminderScheduler;
```

Add constructor:

```php
public function __construct(private ReservationReminderScheduler $reminderScheduler) {}
```

In `persistReservation`, after `$reservation = Reservation::create(...)` and inside the same transaction (after availability is booked and document created), add:

```php
$this->reminderScheduler->scheduleFor($reservation);
```

In `updateReservation`, after `$reservation->update($data);` and inside the transaction, add:

```php
$this->reminderScheduler->scheduleFor($reservation);
```

- [ ] **Step 2: Commit**

```bash
git add app/Services/ReservationService.php
git commit -m "feat: recalculate scheduled reminders on reservation create/update"
```

---

## Task 8: Write feature tests

**Files:**
- Create: `tests/Feature/ScheduledNotificationTest.php`

- [ ] **Step 1: Write tests**

```php
<?php

use App\Jobs\DispatchScheduledNotification;
use App\Models\Building;
use App\Models\Customer;
use App\Models\Reservation;
use App\Models\ScheduledNotification;
use App\Models\Unit;
use App\Models\User;
use App\Notifications\ReservationReminder;
use App\Services\ReservationReminderScheduler;
use Illuminate\Support\Facades\Queue;

it('creates scheduled reminders for a confirmed reservation', function () {
    $reservation = Reservation::factory()->create([
        'status' => 'confirmed',
        'check_in_date' => now()->addDay()->format('Y-m-d'),
        'check_out_date' => now()->addDays(3)->format('Y-m-d'),
    ]);

    (new ReservationReminderScheduler)->scheduleFor($reservation);

    expect(ScheduledNotification::where('reservation_id', $reservation->id)->count())->toBe(2);
});

it('creates evacuate reminder for a checked_in reservation', function () {
    $reservation = Reservation::factory()->create([
        'status' => 'checked_in',
        'check_in_date' => now()->subDay()->format('Y-m-d'),
        'check_out_date' => now()->addDay()->format('Y-m-d'),
    ]);

    (new ReservationReminderScheduler)->scheduleFor($reservation);

    expect(ScheduledNotification::where('reservation_id', $reservation->id)->count())->toBe(3);
    expect(ScheduledNotification::where('reservation_id', $reservation->id)->where('type', 'evacuate_reminder')->exists())->toBeTrue();
});

it('cancels old reminders when reservation dates change', function () {
    $reservation = Reservation::factory()->create([
        'status' => 'confirmed',
        'check_in_date' => now()->addDay()->format('Y-m-d'),
        'check_out_date' => now()->addDays(3)->format('Y-m-d'),
    ]);

    (new ReservationReminderScheduler)->scheduleFor($reservation);
    $originalCount = ScheduledNotification::where('reservation_id', $reservation->id)->count();

    $reservation->update([
        'check_in_date' => now()->addDays(5)->format('Y-m-d'),
        'check_out_date' => now()->addDays(8)->format('Y-m-d'),
    ]);

    (new ReservationReminderScheduler)->scheduleFor($reservation);

    expect(ScheduledNotification::pendingForReservation($reservation->id)->count())->toBe($originalCount);
    expect(ScheduledNotification::where('reservation_id', $reservation->id)->whereNotNull('cancelled_at')->count())->toBe($originalCount);
});

it('dispatches a due notification to the users in-app notifications', function () {
    $user = User::factory()->create();
    $customer = Customer::factory()->create(['id' => $user->id]);
    $reservation = Reservation::factory()->create([
        'customer_type' => Customer::class,
        'customer_id' => $customer->id,
        'status' => 'checked_in',
        'check_in_date' => now()->subDay()->format('Y-m-d'),
        'check_out_date' => now()->format('Y-m-d'),
    ]);

    $scheduled = ScheduledNotification::create([
        'notifiable_type' => Customer::class,
        'notifiable_id' => $customer->id,
        'reservation_id' => $reservation->id,
        'type' => 'evacuate_reminder',
        'send_at' => now()->subMinute(),
    ]);

    (new DispatchScheduledNotification($scheduled))->handle();

    expect(\App\Models\Notification::where('user_id', $user->id)->count())->toBe(1);
    expect($scheduled->fresh()->sent_at)->not->toBeNull();
});

it('command dispatches jobs for due notifications', function () {
    Queue::fake();

    $reservation = Reservation::factory()->create([
        'status' => 'checked_in',
        'check_in_date' => now()->subDay()->format('Y-m-d'),
        'check_out_date' => now()->format('Y-m-d'),
    ]);

    ScheduledNotification::create([
        'notifiable_type' => $reservation->customer_type,
        'notifiable_id' => $reservation->customer_id,
        'reservation_id' => $reservation->id,
        'type' => 'evacuate_reminder',
        'send_at' => now()->subMinute(),
    ]);

    $this->artisan('notifications:send-due')->assertSuccessful();

    Queue::assertPushed(DispatchScheduledNotification::class);
});
```

- [ ] **Step 2: Run tests**

```bash
php artisan test tests/Feature/ScheduledNotificationTest.php
```

Expected: all tests pass.

- [ ] **Step 3: Commit**

```bash
git add tests/Feature/ScheduledNotificationTest.php
git commit -m "test: add scheduled notification feature tests"
```

---

## Task 9: Final verification

- [ ] **Step 1: Run full test suite**

```bash
php artisan test
```

Expected: no regressions.

- [ ] **Step 2: Manual smoke test**

Create a reservation via the API/CLI with status `checked_in` and checkout tomorrow. Run:

```bash
php artisan notifications:send-due
```

Expected: no errors. A `scheduled_notifications` row exists and, once `send_at` is in the past, a record appears in the `notifications` table for the user.

---

## Self-Review Checklist

1. **Spec coverage:**
   - Check-in reminder 1 day before check-in ✅ Task 6
   - Checkout reminder 1 day before checkout ✅ Task 6
   - Evacuate reminder 2 hours before checkout ✅ Task 6
   - Scheduled_notifications table ✅ Task 1
   - Scheduler command ✅ Tasks 4-5
   - Queue job dispatch ✅ Task 3
   - Reservation lifecycle management ✅ Task 7
   - In-app database notifications ✅ Task 2
   - Tests ✅ Task 8

2. **Placeholder scan:** No TBD/TODO/fill-in-details found.

3. **Type consistency:** `ScheduledNotification` model uses `morphs('notifiable')`; job checks `Customer` and `PendingCustomer`; service creates rows for both. Notification class constructor matches usage in job.
