diff --git a/README.md b/README.md index c1041f5..3228e5b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Add the repository to your project's `composer.json`: Then run: ```bash -composer require radical-elements/core +composer require boboko/core php artisan vendor:publish --tag=core-assets php artisan migrate ``` diff --git a/composer.json b/composer.json index 930488e..4fc1c42 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,39 @@ { - "name": "boboko/core", - "description": "Core module — authentication and shared panel behaviour", - "type": "library", - "version": "1.0.0", - "autoload": { - "psr-4": { - "Modules\\Core\\": "src/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Core\\Providers\\CoreServiceProvider" - ] - } + "name": "boboko/core", + "description": "Core module — authentication and shared panel behaviour", + "type": "library", + "version": "1.0.0", + "autoload": { + "psr-4": { + "Modules\\Core\\": "src/" } + }, + "require": { + "php": "^8.5", + "lunarphp/lunar": "1.3.0", + "laravel/framework": "^12.0", + "laravel/tinker": "^3.0" + }, + "require-dev": { + "fakerphp/faker": "^1.23", + "laravel/envoy": "^2.12", + "laravel/pail": "^1.2.5", + "laravel/pint": "^1.27", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.6", + "pestphp/pest": "^4.6", + "pestphp/pest-plugin-laravel": "^4.1" + }, + "extra": { + "laravel": { + "providers": [ + "Modules\\Core\\Providers\\CoreServiceProvider" + ] + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_05_22_155229_fix_staff_morph_type_in_permission_tables.php b/database/migrations/2026_05_22_155229_fix_staff_morph_type_in_permission_tables.php new file mode 100644 index 0000000..3c1cabd --- /dev/null +++ b/database/migrations/2026_05_22_155229_fix_staff_morph_type_in_permission_tables.php @@ -0,0 +1,38 @@ +where('model_type', $old) + ->update(['model_type' => $new]); + + DB::table('model_has_permissions') + ->where('model_type', $old) + ->update(['model_type' => $new]); + } + + public function down(): void + { + $old = \Lunar\Admin\Models\Staff::class; + $new = 'staff'; + + DB::table('model_has_roles') + ->where('model_type', $new) + ->update(['model_type' => $old]); + + DB::table('model_has_permissions') + ->where('model_type', $new) + ->update(['model_type' => $old]); + } +}; diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..293ad50 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,139 @@ +# Notifications + +Notifications are driven by `NotificationRegistry` in `Modules\Core\Notification`. Each notification class declares what event it listens to and who to notify, and the registry wires up the event listener automatically. + +--- + +## Async delivery + +All notification classes extend `BaseNotification`, which implements `ShouldQueue` and uses the `Queueable` trait. This means **every notification is delivered asynchronously** via the queue — the HTTP request returns immediately and the email is sent by a queue worker. + +In local development or environments without a queue worker, set `QUEUE_CONNECTION=sync` in `.env` to send emails inline. + +--- + +## Key naming convention + +Notification keys follow the pattern `module.event.refersTo.via`: + +| Segment | Description | Example | +|---|---|---| +| `module` | The module the notification belongs to | `appointment` | +| `event` | The event that triggers it | `booked`, `cancelled`, `rescheduled` | +| `refersTo` | Who the notification is about | `customer`, `staff` | +| `via` | The delivery channel | `mail`, `slack` | + +Example: `appointment.cancelled.customer.mail` + +--- + +## Built-in notifications + +| Key | Class | Event | Channel | +|---|---|---|---| +| `appointment.booked.customer.mail` | `AppointmentBookedCustomerNotification` | `AppointmentBooked` | mail | +| `appointment.booked.staff.mail` | `AppointmentBookedStaffNotification` | `AppointmentBooked` | mail | +| `appointment.cancelled.customer.mail` | `AppointmentCancelledCustomerNotification` | `AppointmentCancelled` | mail | +| `appointment.rescheduled.customer.mail` | `AppointmentRescheduledCustomerNotification` | `AppointmentRescheduled` | mail | + +These are registered in `AppointmentsServiceProvider::boot()`. + +### When AppointmentBooked fires + +`AppointmentBooked` is **not** fired directly from `book()` or `reassign()`. It is fired by `CreateGoogleMeetLinkListener` after the Meet link has been created (or attempted). This means notifications always go out with the Meet link already in the event — if the link couldn't be created, `meetUrl` is `null` and the email templates handle that gracefully. + +### Resend behaviour + +`AppointmentBooked` carries an `isResend: bool` flag (default `false`). When `true`: +- `AppointmentBookedStaffNotification` returns an empty `via()` — no staff email is sent +- `N8nWebhookListener` short-circuits — no webhook is fired + +This flag is set by `AppointmentService::resendConfirmation()` so only the customer notification goes out on a manual resend. + +--- + +## Creating a notification + +Extend `BaseNotification`: + +```php +use Illuminate\Support\Facades\Notification as NotificationFacade; +use Modules\Core\Notification\BaseNotification; + +class AppointmentBookedAdminNotification extends BaseNotification +{ + public function __construct(private readonly AppointmentBooked $event) {} + + public static function getKey(): string + { + return 'appointment.booked.admin'; + } + + public static function listensTo(): string + { + return AppointmentBooked::class; + } + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function notifiable(): \Illuminate\Notifications\AnonymousNotifiable + { + return NotificationFacade::route('mail', 'admin@example.com'); + } +} +``` + +Then register it in your service provider: + +```php +NotificationRegistry::get()->register([AppointmentBookedAdminNotification::class]); +``` + +### Notifiable: anonymous vs model + +Return an `AnonymousNotifiable` (via `Notification::route()`) when there is no Eloquent model involved — e.g. a fixed email address or Slack webhook pulled from the event or config. + +Return an Eloquent model when you have one. The model must implement `routeNotificationFor` for the relevant channel (Laravel does this automatically for `mail` via the `Notifiable` trait). + +```php +public function notifiable(): User +{ + return User::find($this->event->userId); +} +``` + +--- + +## Replacing a built-in notification + +Register a new class with the same key. The registry replaces the previous class under that key, so the existing event listener will dispatch the new class instead. + +```php +NotificationRegistry::get()->register([MyCustomMailNotification::class]); // getKey() returns 'appointment.booked.customer.mail' +``` + +If overriding a module notification from `AppServiceProvider`, do it inside `booted()` to ensure module providers have already run: + +```php +public function boot(): void +{ + $this->app->booted(function () { + NotificationRegistry::get()->register([MyCustomSlackNotification::class]); + }); +} +``` + +--- + +## Removing a notification + +```php +NotificationRegistry::get()->unregister('appointment.booked.customer.mail'); +``` + +The event listener remains registered but short-circuits without dispatching anything. + +Again, call this inside `booted()` when doing it from `AppServiceProvider`.