# Cursor Rules for bongo/calendar

## Project Overview

This is a Laravel package (`bongo/calendar`) providing a multi-step public appointment
booking system. Customers pick a calendar and date on a single page (Step 1), enter
their details (Step 2), and either confirm a provisional reservation (Step 3) or land
on the final confirmation page (Step 4). Staff manage calendars, appointments, and
date restrictions from the admin backend and via a Sanctum-protected JSON API.

**Key Features**:
- Two-step public booking wizard with session-based state (`calendar_id`, `appointment_date`)
- Provisional vs. firm appointments; provisional bookings expire after a configurable
  number of *working* hours (weekends skipped via `Support\WorkingTime`)
- Cron-driven expiry/cleanup (`calendar:expire-appointments`, every 15 minutes) with an
  opt-in on-visit fallback (`update_by_visit`)
- Optional per-calendar weekly booking limit (`weekly_limit`, 0/null = unlimited, max 7)
- Optional admin approve/reject mediator layer (gated on `calendar.approval_required`)
- Date restrictions per calendar (`unavailable` or `restricted` with an admin message)
- Per-calendar day-of-week availability (monday–sunday boolean columns)
- 9 domain events, 5 mail-sending listeners, 10 mailables (customer + admin pairs)
- Spam protection: `Spatie\Honeypot\ProtectAgainstSpam` middleware on all public POSTs
  plus `Bongo\Captcha\Rules\Captcha` reCAPTCHA rules in every frontend form request

**Dependencies** (`composer.json`): PHP ^8.2, `bongo/framework` ^3.0,
`illuminate/contracts` ^10.0||^11.0, `spatie/laravel-honeypot` ^4.0.
Dev: orchestra/testbench, phpunit, larastan/phpstan (level 1), laravel/pint,
laravel/sanctum, plus bongo dev packages (blade, setting, user, ...).

## Directory Structure

```
src/
├── Actions/                  # 8 static-method business-logic classes
│   ├── CleanUpProvisionalAppointments.php   # forceDelete() old trashed provisionals
│   ├── CountWeeklyAppointments.php          # firm, non-rejected count for an ISO week
│   ├── ExpireProvisionalAppointments.php    # soft delete + fire CalendarAppointmentExpired
│   ├── FindCalendar.php                     # byUuid() / byId(), active only
│   ├── FindCalendarAppointment.php          # byUuid()
│   ├── GetAvailableCalendars.php            # active calendars ordered by name
│   ├── GetAvailableDates.php                # day-by-day status array for a month
│   └── GetAvailableUsers.php                # execute() / asDropdown()
├── Commands/
│   └── ExpireAppointmentsCommand.php        # artisan calendar:expire-appointments
├── Config/
│   └── calendar.php                         # prefixes, expiry, feature flags, recaptcha
├── Enums/                    # 4 backed string enums (see Enums section)
├── Events/                   # 9 events (appointment lifecycle + calendar CRUD)
├── Exceptions/               # 4 exceptions (not found / expired / rejected)
├── Http/
│   ├── Controllers/
│   │   ├── Api/              # CalendarAppointmentController, CalendarRestrictionController
│   │   ├── Backend/          # CalendarController, CalendarDatatableController,
│   │   │                     # CalendarAppointmentBackendController,
│   │   │                     # CalendarRestrictionBackendController
│   │   └── Frontend/         # Step1Controller … Step4Controller
│   ├── Requests/
│   │   ├── Api/              # Store/Update CalendarAppointment + CalendarRestriction
│   │   ├── Backend/          # StoreCalendarRequest, UpdateCalendarRequest
│   │   └── Frontend/         # StoreStep1Request, StoreStep2Request, StoreStep3Request
│   └── Resources/            # Calendar / CalendarAppointment / CalendarRestriction Resource
├── Listeners/                # 5 SendAppointment*Notification listeners (all send mail)
├── Mailables/                # 10 mailables: {Action}Mailable + Admin{Action}Mailable
├── Migrations/               # 4 migrations (see Migrations section)
├── Models/                   # Calendar, CalendarAppointment, CalendarRestriction
├── Routes/                   # api.php, backend.php, frontend.php
├── Rules/
│   └── CalendarDateAvailable.php  # shared date-availability validation rule
├── Seeders/
│   └── PackageSeeder.php
├── Support/
│   └── WorkingTime.php            # addWorkingHours() — weekend-skipping expiry maths
├── Translations/en/               # backend.php, frontend.php, mail.php
├── Views/
│   ├── backend/                   # index/create/edit/show + appointments/ + restrictions/
│   ├── frontend/                  # layout + step_1 … step_4 with partials/
│   └── mail/                      # HTML + _plain view per mailable, shared partials/
└── CalendarServiceProvider.php
database/
├── factories/                # CalendarFactory, CalendarAppointmentFactory,
│                             # CalendarRestrictionFactory
└── seeders/
tests/                        # PHPUnit: Unit/ (actions, enums, models, rules, support,
                              # commands, listeners, mailables) + Feature/ (wizard, API)
public/js/calendar.js         # Step 1 auto-submit + restricted-date display (published asset)
```

## How the Package Extends bongo/framework

`Bongo\Calendar\CalendarServiceProvider` extends
`Bongo\Framework\Providers\AbstractServiceProvider`
(`default/framework/src/Providers/AbstractServiceProvider.php`) with
`protected string $module = 'calendar'`. The base class auto-registers:

- **Config** — `src/Config/calendar.php` merged as `config('calendar.*')`
- **Routes** — from `src/Routes/`:
  - `api.php` → `/api/...` prefix, `api.*` route names, `auth:sanctum` middleware
  - `backend.php` → admin prefix, `backend.*` route names, `auth` + `employee` middleware
  - `frontend.php` → `frontend.*` route names, web middleware
- **Views** — namespace `calendar::` from `src/Views/`
- **Migrations** — `src/Migrations/`
- **Translations** — `src/Translations/` as `calendar::`
- **Commands** — via `protected array $commands = [ExpireAppointmentsCommand::class]`
- **Listeners** — via the `protected array $listeners` map

The provider adds three overrides (verify in `src/CalendarServiceProvider.php`):

1. `bootListeners(): void` — unsets the `CalendarAppointmentApproved` /
   `CalendarAppointmentRejected` bindings when `config('calendar.approval_required')`
   is false, then calls `parent::bootListeners()`. The approval layer is dormant by default.
2. `bootCronSchedule(Schedule $schedule): void` — schedules
   `calendar:expire-appointments` `->everyFifteenMinutes()->withoutOverlapping()`
   when `config('calendar.update_by_cron')` is true. Consuming sites MUST run
   `php artisan schedule:run` from system cron or provisional bookings never expire
   (unless `update_by_visit` is enabled).
3. `bootAssets(): void` — publishes `public/` to the app's public path under the
   `calendar:assets` tag (`php artisan vendor:publish --tag=calendar:assets`).

Do not manually register routes/views/config — the base class does it. The `$module`
value must match the config filename and the view/translation namespace.

## Architecture Patterns

### Two-Step Session-Based Booking Wizard

State travels between Step 1 and Step 2 in the PHP session, never the URL:

```php
// Step1Controller::store() — after validating StoreStep1Request
session([
    'calendar_id' => $request->validated('calendar_id'),   // calendar UUID
    'appointment_date' => $request->validated('date'),
]);

// Step2Controller reads both via resolveCalendarAndDate(), creates the
// appointment, then createAppointment() clears the session:
session()->forget(['calendar_id', 'appointment_date']);
```

Rules that follow from the code:
- `Step1Controller::show()` clears the session keys, optionally runs expiry/cleanup
  (`update_by_visit`), and renders every active calendar with a `days` attribute from
  `GetAvailableDates::forCalendar($calendar, $selectedMonth)`. Month navigation uses a
  `?month=YYYY-MM` query param validated by the private `resolveMonth()` (regex
  `/^\d{4}-\d{2}$/`, past months snap to the current month).
- Clicking a date auto-submits the per-calendar form via `public/js/calendar.js` —
  there is no explicit "Continue" button on Step 1.
- `Step2Controller::show()`/`store()` redirect to Step 1 when `session('calendar_id')`
  is empty; missing/invalid session data throws `CalendarNotFoundException` or
  `CalendarAppointmentNotFoundException`.
- Steps 3 and 4 are UUID route-model-bound (`{calendarAppointment:uuid}`), so they
  also work from email links. Both clear the wizard session keys and guard against
  expired (`CalendarAppointmentExpiredException`) and rejected
  (`CalendarAppointmentRejectedException`) appointments. Step 4 bounces provisional /
  unconfirmed appointments back to Step 3; Step 3 forwards already-confirmed ones to
  Step 4 with a flash message.
- All views receive `currentStep` for the wizard progress indicator (Step 1 passes 1;
  Steps 2–4 all pass 2).

### Provisional vs Firm Appointments

`Step2Controller::store()` branches on the validated `type`:

- **Provisional** — sets
  `expires_at = WorkingTime::addWorkingHours(now(), (int) config('calendar.expiry_hours', 72))`
  and redirects to Step 3 (`frontend.calendar.step_3.show`, by UUID).
- **Firm** — sets `confirmed_at = now()` and redirects straight to Step 4.

Both paths set `reserved_at = now()`, fire `CalendarAppointmentReserved`, and clear the
session. `Step3Controller::store()` upgrades a provisional booking: sets
`type = CalendarAppointmentTypeEnum::Firm`, `confirmed_at = now()`, fires
`CalendarAppointmentConfirmed`, redirects to Step 4.
`Api\CalendarAppointmentController::store()` applies the same `expires_at` logic.

### Working-Hours Expiry — Support\WorkingTime

`WorkingTime::addWorkingHours(Carbon $from, int $hours): Carbon` walks forward hour by
hour and only decrements the remaining count on non-weekend hours — so the default 72
hours means 3 *working* days. Always use this helper when computing `expires_at`;
never `now()->addHours()`.

### Expiry / Cleanup Actions

- `ExpireProvisionalAppointments::execute()` — selects Reserved + Provisional rows with
  `expires_at < now()`, fires `CalendarAppointmentExpired` per row, then soft deletes.
- `CleanUpProvisionalAppointments::execute()` — `onlyTrashed()` Reserved + Provisional
  rows older than `config('calendar.cleanup_days')` days, then `forceDelete()` (permanent).
- `ExpireAppointmentsCommand` (`calendar:expire-appointments`) runs both and prints a
  confirmation; it is the scheduled entry point.

### Date Availability — GetAvailableDates + CalendarDateAvailable

`GetAvailableDates::forCalendar(Calendar $calendar, ?string $month = null): array`
returns one map per day of the month: `['date', 'day', 'day_name', 'status', 'message']`.
Status precedence (from the code): `past` → `closed` (day-of-week off) → `booked`
(existing non-rejected appointment) → `unavailable` → `restricted` → weekly limit
reached (reported as `booked`) → `available`. Messages come from
`trans('calendar::frontend.date_*')` keys — the admin-authored restriction message is
never exposed publicly.

`Rules\CalendarDateAvailable` (implements `Illuminate\Contracts\Validation\ValidationRule`)
is the shared server-side gate, constructed as
`new CalendarDateAvailable(Calendar $calendar, string $context = 'appointment', ?int $ignoreId = null)`:
- rejects past dates and non-operating days for both contexts
- `appointment` context: rejects double-booking, `unavailable` restrictions, and
  weekly-limit breaches
- `restriction` context: rejects dates that already have appointments or a restriction
Use it in form requests (see `StoreStep1Request`) instead of duplicating date checks.

### Weekly Limit

`Calendar.weekly_limit` (nullable int, validated `min:0`, `max:7`; 0/null = unlimited).
`CountWeeklyAppointments::forWeekOf(Calendar $calendar, Carbon $date, ?int $ignoreId = null): int`
counts **Firm**, non-Rejected appointments in the Monday–Sunday week containing `$date`.
Both `GetAvailableDates` and `CalendarDateAvailable` consult it — provisional
appointments do NOT count toward the limit.

### Approval Mediator Layer (Optional)

`Api\CalendarAppointmentController::approve()` / `reject()` start with
`abort_unless(config('calendar.approval_required'), 403)`, no-op when
`hasBeenActioned()`, then set status + `approved_at`/`approved_by` (or rejected
equivalents) and fire the matching event. The Approved/Rejected listener bindings are
only registered when the flag is on (see `bootListeners()`).

### Events → Listeners → Mailables

Registered in `$listeners` (appointment lifecycle):

| Event | Listener | Mailables sent |
|-------|----------|----------------|
| `CalendarAppointmentReserved` | `SendAppointmentReservedNotification` | `ReservedMailable` + `AdminReservedMailable` |
| `CalendarAppointmentConfirmed` | `SendAppointmentConfirmedNotification` | `ConfirmedMailable` + `AdminConfirmedMailable` |
| `CalendarAppointmentApproved`* | `SendAppointmentApprovedNotification` | `ApprovedMailable` + `AdminApprovedMailable` |
| `CalendarAppointmentRejected`* | `SendAppointmentRejectedNotification` | `RejectedMailable` + `AdminRejectedMailable` |
| `CalendarAppointmentExpired` | `SendAppointmentExpiredNotification` | `ExpiredMailable` + `AdminExpiredMailable` |

\* Only bound when `calendar.approval_required` is true.

Unbound events fired by `Backend\CalendarController` and the API restriction
controller for consuming apps to listen to: `CalendarCreated`, `CalendarUpdated`,
`CalendarDeleted`, `CalendarRestrictionCreated`.

Event classes are plain data carriers with promoted public constructor properties
(e.g. `public CalendarAppointment $calendarAppointment`) using `Dispatchable`,
`InteractsWithSockets`, `SerializesModels`.

Listener guard pattern (copy it for new lifecycle states):

```php
public function handle(CalendarAppointmentReserved $event): void
{
    $appointment = $event->calendarAppointment;
    $appointment->loadMissing('calendar.user');

    if (empty($appointment->email) || ! $appointment->hasBeenReserved()) {
        return;
    }

    Mail::to($appointment->email)->send(new ReservedMailable($appointment));

    if ($appointment->calendar?->user?->email) {
        Mail::to($appointment->calendar->user->email)->send(new AdminReservedMailable($appointment));
    }
}
```

Mailables use the `build()` pattern (never `envelope()`/`content()`), `from()` with
`config('settings.mail_from_address')` / `config('settings.mail_from_name')`,
`replyTo()` with `setting('client::company.email')` / `setting('client::company.name')`,
a `trans('calendar::mail.*')` subject, and paired `->view()` + `->text()` templates
(`calendar::mail.reserved` / `calendar::mail.reserved_plain`).

### Static Action Classes

All business logic lives in `src/Actions/` classes with **static** entry points —
`execute()`, `forCalendar()`, `forWeekOf()`, `byUuid()`, `byId()`, `asDropdown()`.
No instantiation, no interfaces, models referenced directly. Controllers stay thin and
delegate. `GetAvailableUsers` resolves the user model from
`config('auth.providers.users.model')` and excludes inactive users, `developer`-type
users, and users already assigned to another calendar.

## Models

All three models extend `Bongo\Framework\Models\AbstractModel` and use `HasFactory`,
`HasUUID` (framework trait — auto-generates `uuid`, enables `{model:uuid}` binding),
and `SoftDeletes`, with `newFactory()` pointing at
`Bongo\Calendar\Database\Factories\*Factory`.

### Calendar (`calendars`)
- `$fillable`: `user_id`, `name`, `status`, `monday`…`sunday`, `weekly_limit`
- Casts: `status` → `CalendarStatusEnum`, day columns → `boolean`,
  `weekly_limit` → `integer`
- `Calendar::DAYS_OF_WEEK` — ordered array of lowercase day names
- Relations/guards: `user(): BelongsTo` (to `Bongo\User\Models\User`), `hasUser()`,
  `appointments(): HasMany`, `hasAppointments()`, `restrictions(): HasMany`,
  `hasRestrictions()`
- Helpers: `isActive()`, `isInactive()`,
  `getCaptchaActionAttribute()` — `'calendar_step_1_'.substr((string) $this->uuid, 0, 8)`
  (per-calendar reCAPTCHA action used by `StoreStep1Request`)

### CalendarAppointment (`calendar_appointments`)
- `$fillable`: `calendar_id`, `date`, `name`, `email`, `notes`, `type`, `status`,
  `reserved_at`, `confirmed_at`, `expires_at`, `approved_at`, `approved_by`,
  `rejected_at`, `rejected_by`
- Casts: `date` → `date`, `type`/`status` → enums, all `*_at` → `datetime`
- Status: `isReserved()`, `isApproved()`, `isRejected()`
- Type: `isProvisional()`, `isFirm()`,
  `isExpired()` (provisional AND `expires_at` in the past)
- Lifecycle: `hasBeenReserved()`, `hasBeenConfirmed()`, `hasBeenApproved()`,
  `hasBeenRejected()`, `hasBeenActioned()` (approved OR rejected)
- `calendar(): BelongsTo`, `hasCalendar()`

### CalendarRestriction (`calendar_restrictions`)
- `$fillable`: `calendar_id`, `date`, `type`, `message`
- Casts: `date` → `date`, `type` → `CalendarRestrictionTypeEnum`
- `calendar(): BelongsTo`, `hasCalendar()`, `isUnavailable()`, `isRestricted()`

## Enums

All in `src/Enums/`, backed string enums implementing
`Bongo\Framework\Enums\ArrayInterface` + `DefaultInterface` with the `WithArray` trait
(gives `toArray()`, `getKeys()`, `getValues()`, `asMultiSelect()`), and a static
`getDefault(): string`:

| Enum | Cases (PascalCase => lowercase value) | Default |
|------|---------------------------------------|---------|
| `CalendarStatusEnum` | `Active`, `Inactive` | `Active` |
| `CalendarAppointmentStatusEnum` | `Reserved`, `Approved`, `Rejected` | `Reserved` |
| `CalendarAppointmentTypeEnum` | `Provisional`, `Firm` | `Provisional` |
| `CalendarRestrictionTypeEnum` | `Unavailable`, `Restricted` | `Unavailable` |

Validate enum input with `Rule::enum(CalendarAppointmentTypeEnum::class)`.

## Routes

### Frontend (`src/Routes/frontend.php`) — public wizard
Prefix `config('calendar.frontend_prefix', 'bookings')`, names `frontend.calendar.*`,
every POST wrapped in `ProtectAgainstSpam::class`:

- `GET /bookings` → `frontend.calendar.index` (`Step1Controller@index`, redirect to step 1)
- `GET /bookings/date` → `frontend.calendar.step_1.show`
- `POST /bookings/date/store` → `frontend.calendar.step_1.store`
- `GET|POST /bookings/details[/store]` → `frontend.calendar.step_2.show|store`
- `GET|POST /bookings/reserved/{calendarAppointment:uuid}` → `frontend.calendar.step_3.show|store`
- `GET /bookings/confirmed/{calendarAppointment:uuid}` → `frontend.calendar.step_4.show`

Step URL segments come from `config('calendar.step_N.prefix')`.

### Backend (`src/Routes/backend.php`) — admin, `backend.calendar.*`
Prefix `config('calendar.backend_prefix', 'calendars')`; `auth` + `employee`
middleware applied by the framework. Explicit routes (never `Route::resource()`):
`index`, `create`, `store`, `datatable` (`CalendarDatatableController@index`), then
under `{calendar}`: `show`, `edit`, `update` (POST), `destroy` (ANY `delete`), plus
nested read-only pages:
- `backend.calendar.appointment.index` → `CalendarAppointmentBackendController@index`
  (`/admin/calendars/{calendar}/appointments`)
- `backend.calendar.restriction.index` → `CalendarRestrictionBackendController@index`
  (`/admin/calendars/{calendar}/restrictions`)

### API (`src/Routes/api.php`) — `api.calendar.*`, `auth:sanctum`
Under `api/{config('calendar.api_prefix')}/{calendar}`:
- appointments: `GET /`, `POST /`, then `{calendarAppointment}`:
  `POST update|approve|reject|delete`
- restrictions: `GET /`, `POST /`, then `{calendarRestriction}`: `POST update|delete`
Mutations are POST with a verb segment — not PUT/DELETE.

## Configuration (`src/Config/calendar.php`)

| Key | Default | Notes |
|-----|---------|-------|
| `enabled` | `true` | Package toggle |
| `expiry_hours` | `72` | Working hours until provisional expiry |
| `cleanup_days` | `30` | Days before trashed provisionals are force-deleted |
| `update_by_cron` | `true` | Schedule the expiry command every 15 min |
| `update_by_visit` | `false` | Also expire/clean inline on Step 1 load (opt-in) |
| `approval_required` | `false` | Enables approve/reject API + listener bindings |
| `image_enabled` | `false` | Render `.calendar-image.<slug>` div on Step 1 cards |
| `api_prefix` / `backend_prefix` / `frontend_prefix` | `calendars` / `calendars` / `bookings` | |
| `appointment.api_prefix` / `appointment.backend_prefix` | `appointments` | |
| `restriction.api_prefix` / `restriction.backend_prefix` | `restrictions` | |
| `step_1..step_4` | `label` + `prefix` pairs | Wizard labels/URL segments |
| `recaptcha.enabled` / `recaptcha.min_score` | `true` / `0.5` | Combined with `setting()->captchaEnabled()` |

## Migrations (`src/Migrations/`)

- `2026_01_01_000001_create_calendars_table.php`
- `2026_01_01_000002_create_calendar_appointments_table.php`
- `2026_01_01_000003_create_calendar_restrictions_table.php`
- `2026_06_01_000001_add_weekly_limit_to_calendars_table.php`

Conventions (visible in the appointments migration): `Schema::hasTable()` guard at the
top; `$table->increments('id')` + `$table->uuid()->unique()`; foreign keys as
`unsignedInteger` + explicit `$table->foreign()` (never `foreignId()`, which
mismatches `increments`); string status/type columns defaulted from enum cases and
indexed; full audit columns (`created_by`, `updated_by`, `deleted_by`) and
`deleted_at`. Note: `(calendar_id, date)` is a plain **index**, not unique — conflict
logic (including re-booking a rejected date) lives in `CalendarDateAvailable`.

## Coding Conventions

- `declare(strict_types=1)` in every PHP file (enforced by pint.json)
- Explicit return types on every method; short nullable syntax (`?Calendar`)
- Actions use static methods; one action, one responsibility
- Controllers extend `Bongo\Framework\Http\Controllers\AbstractController` and stay
  thin — validation in form requests, logic in actions, events fired after writes
- Form requests extend `Illuminate\Foundation\Http\FormRequest` directly; no
  `authorize()` override; array rule syntax; `email:rfc,dns` for email fields
- Models: explicit `$fillable` (never `$guarded = []`), `$casts` with enum classes,
  one trait per `use` line, typed relationship returns, `loadMissing()` in guard
  helpers, null-safe traversal (`$appointment->calendar?->user?->email`)
- Route model binding by UUID on the frontend (`{calendarAppointment:uuid}` via the
  `HasUUID` trait); backend/API bind `{calendar}` etc. by default key
- Enums: backed strings, PascalCase cases, lowercase values, `ArrayInterface` +
  `DefaultInterface` + `WithArray`
- Views: snake_case filenames under the `calendar::` namespace; shared fragments in
  `partials/`; translations via `trans('calendar::frontend.*')` etc.
- Backend redirects use the framework's `->success()` / `->error()` response macros
  with `trans('calendar::backend.*')` messages
- Never use `env()` outside config files; read `config('calendar.*')` everywhere else

## Common Tasks

### Add a new appointment lifecycle state
1. Add the case to `CalendarAppointmentStatusEnum` (and a timestamp column migration
   if needed, e.g. `cancelled_at`)
2. Add `isCancelled()` / `hasBeenCancelled()` helpers to `CalendarAppointment`
3. Create the event (`CalendarAppointmentCancelled`, promoted public property)
4. Create `CancelledMailable` + `AdminCancelledMailable` and the four mail views
   (`cancelled`, `cancelled_plain`, `admin_cancelled`, `admin_cancelled_plain`)
5. Create `SendAppointmentCancelledNotification` using the guard pattern
6. Register the mapping in `CalendarServiceProvider::$listeners`
7. Add an API route + controller method mirroring `approve()`/`reject()`
8. Add unit tests (listener, mailable, enum) and a feature test

### Add a restriction type
1. Add the case to `CalendarRestrictionTypeEnum` + a boolean helper on
   `CalendarRestriction`
2. Handle the new status in `GetAvailableDates::forCalendar()` (mind the precedence
   chain) and, if it blocks booking, in `CalendarDateAvailable`
3. Add a `calendar::frontend.date_*` translation and update the Step 1 partials

### Change a consuming site's URLs
Override `frontend_prefix` / `backend_prefix` / `api_prefix` (and `step_N.prefix`)
in the app's `config/calendar.php` — never edit the route files.

### Run expiry manually
```php
Bongo\Calendar\Actions\ExpireProvisionalAppointments::execute();
Bongo\Calendar\Actions\CleanUpProvisionalAppointments::execute();
```
or `php artisan calendar:expire-appointments`.

## Testing & Commands

```bash
composer test              # vendor/bin/phpunit --no-coverage
composer test:coverage     # XDEBUG_MODE=coverage vendor/bin/phpunit
composer analyse           # vendor/bin/phpstan analyse --memory-limit=256M (level 1)
composer format            # vendor/bin/pint
vendor/bin/pint --test     # style check only
composer build             # testbench workbench build
composer start             # testbench serve (local dev)
composer clear             # purge testbench skeleton
php artisan vendor:publish --tag=calendar:assets --force
```

Test facts (from `tests/TestCase.php` and `phpunit.xml.dist`):
- PHPUnit only (no Pest); `#[Test]` attributes; namespace `Bongo\Calendar\Tests`
- `TestCase` extends Orchestra Testbench, uses `RefreshDatabase`, sqlite `:memory:`,
  random execution order, `failOnWarning`/`failOnRisky` enabled
- Registers `HoneypotServiceProvider` + `CalendarServiceProvider`, aliases the
  `noIndex` and `employee` middleware, creates a minimal `users` table, and loads
  `src/Migrations`
- `tests/Stubs/CaptchaRule.php` + `tests/Helpers.php` are autoloaded (dev) to stub
  the captcha/setting layer
- Factories live in `database/factories/` under
  `Bongo\Calendar\Database\Factories`
- PHPStan is level 1 with the standard monorepo ignore rules — never raise the level
  or add baselines without approval

## Do NOT

- Compute `expires_at` with plain `addHours()` — always `WorkingTime::addWorkingHours()`
- Count provisional appointments toward `weekly_limit` — only Firm, non-Rejected count
- Expose `CalendarRestriction::$message` on the public wizard — public strings come
  from `calendar::frontend.date_*` translations
- Add a DB unique constraint on `(calendar_id, date)` — availability conflicts are
  validated in `CalendarDateAvailable` so rejected dates can be re-booked
- Register routes/views/config manually in the service provider — the framework
  auto-registers from `$module`
- Skip the `ProtectAgainstSpam` middleware or the `Captcha` rule on new public POSTs
- Use `Route::resource()` — declare routes explicitly, mutations as POST verbs
- Fire mail directly from controllers — fire the event and let the listener guard/send
