Compare commits
22 Commits
main
..
31ed52500a
| Author | SHA1 | Date | |
|---|---|---|---|
| 31ed52500a | |||
| b8d31e3dc4 | |||
| 95b369174d | |||
| bebcab88fa | |||
| 914b7f97f3 | |||
| b6934e1fb5 | |||
| 76f75c5581 | |||
| 231f5679ef | |||
| a905320d50 | |||
| 4f0f20659d | |||
| 98dacef556 | |||
| da6d51b7b2 | |||
| 0e55e36cea | |||
| da9cd9bbe0 | |||
| 41c9454334 | |||
| fa908cdcaf | |||
| 894352b43f | |||
| 1aeb57f130 | |||
| 6be47aa35a | |||
| 79f7f50706 | |||
| 54b35ee087 | |||
| dfffdd343b |
@@ -0,0 +1,15 @@
|
||||
---
|
||||
paths:
|
||||
- 'app-modules/shared/src/Bnfexpress/**'
|
||||
---
|
||||
|
||||
# Bnfexpress
|
||||
|
||||
## bnfexpress admin API calls go through BnfexpressAdminClient
|
||||
Signed backend-to-backend calls to bnfexpress's admin API (EV FAQs, agent instructions, chat history) go through `Modules\Shared\Bnfexpress\BnfexpressAdminClient` — do not call `Http::` directly against BNFEXPRESS_AI_API_URL elsewhere.
|
||||
|
||||
Auth is HMAC, not JWT/session: X-Client-Id/X-Timestamp/X-Signature per `BnfexpressSignature::headers()`, signed over `METHOD\nPATH\nTIMESTAMP\nRAW_BODY` (path only, no query string; empty string body for GET/DELETE). Timestamps must be generated fresh per request (server rejects >300s skew) — never cache/reuse a signed header set.
|
||||
|
||||
Config lives in `config('services.bnfexpress')` (BNFEXPRESS_AI_API_URL/CLIENT_ID/CLIENT_SECRET in .env). The client_secret must match bnfexpress's own ADMIN_SERVICE_CLIENTS entry for ev_admin — get it from whoever manages that deploy.
|
||||
|
||||
Non-2xx responses throw `BnfexpressApiException` carrying the gateway's `{"detail": "..."}` message. Verify signing end-to-end with `php artisan bnfexpress:smoke-test` before wiring up any UI.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Project Rules Index
|
||||
|
||||
Before planning or editing, find the row whose globs match the file's path and read that rule file.
|
||||
|
||||
| Applies to | Rule file |
|
||||
| --- | --- |
|
||||
| app-modules/shared/src/Bnfexpress/** | .ai/rules/bnfexpress.md |
|
||||
| app-modules/*/src/Filament/Pages/** | .ai/rules/pages.md |
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
paths:
|
||||
- 'app-modules/*/src/Filament/Pages/**'
|
||||
---
|
||||
|
||||
# Pages
|
||||
|
||||
## Non-Resource Filament pages need an explicit table-rendering view + deferLoading-aware tests
|
||||
A `Filament\Pages\Page implements HasTable` (not a Resource) does NOT render its table automatically — it must set `protected string $view = '<module>::filament.pages.<slug>';` pointing at a Blade file containing `<x-filament-panels::page>{{ $this->table }}</x-filament-panels::page>` (see `ManageFaqs`/`ViewEvChatHistory`/`BookingsRevenueReport`). Omitting this silently renders an empty page — no error, just a blank `fi-page-content`.
|
||||
|
||||
Filament v4 tables default to deferred loading. In Pest/Livewire tests, call `->loadTable()` before any `assertSee()`/`assertCanSeeTableRecords()` on a freshly-mounted component, or the table body won't be in the rendered HTML yet.
|
||||
|
||||
For custom-data (`->records()`-backed, non-Eloquent) tables: use `->callTableAction($name, $record, data: [...])` / `->mountTableAction(...)` (not the generic `->callAction()`, which targets page-level actions and misses table header/record actions), and use `->assertMountedActionModalSee(...)` to check `->modalContent()` output — modal content is lazily rendered and won't appear in a plain `->html()`/`->assertSee()` snapshot even after mounting the action. See `[[project_internachi_modular]]`-style module layout in `app-modules/ai-agent`.
|
||||
|
||||
## Custom-data table bulk actions: fetchSelectedRecords(false) still hydrates full rows
|
||||
On a `Table::records()`-backed (non-Eloquent) page, `BulkAction::make(...)->fetchSelectedRecords(false)` does NOT skip hydration the way it does for an Eloquent table — the `Collection $records` passed to `->action()` still contains full row arrays (keyed by the record key), not bare ids. Use `$records->keys()->all()` to get just the selected ids; `$records->all()`/`$records->values()` gives you full row data instead. See `ManageSuggestions::deleteSelectedBulkAction()` / `ManageSuggestionMisses::promoteBulkAction()`.
|
||||
|
||||
Also: `BnfexpressAdminClient`'s non-2xx handling (`errorMessage()`) must handle `detail` being a list of `{msg, ...}` objects, not just a string — FastAPI's own request-validation failures (422s) return `detail` in that shape, and casting it straight to `(string)` silently produces the literal "Array".
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"laravel-boost"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
+18
-3
@@ -53,9 +53,19 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
BOOKING_BACK_SEAT_ENABLED=
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=
|
||||
BOOKING_FRONT_SEAT_ENABLED=true
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
|
||||
BOOKING_BACK_SEAT_ENABLED=true
|
||||
BOOKING_BACK_SEAT_MAX_PER_BOOKING=3
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=true
|
||||
BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=4
|
||||
|
||||
BOOKING_ADMIN_EMAILS="example@gmail.com"
|
||||
|
||||
SMS_ENABLED=false
|
||||
SMS_SERVER=
|
||||
SMS_TOKEN=
|
||||
SMS_SENDER=
|
||||
|
||||
KBZ_APP_ID=
|
||||
KBZ_MERCHANT_CODE=
|
||||
@@ -89,3 +99,8 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
FASTAPI_AGENT_JWT_SECRET=
|
||||
FASTAPI_AGENT_JWT_ALGORITHM=HS256
|
||||
|
||||
# Must match the ev_admin entry in bnfexpress's own ADMIN_SERVICE_CLIENTS.
|
||||
BNFEXPRESS_AI_API_URL=http://bnfexpress-app:8000
|
||||
BNFEXPRESS_AI_CLIENT_ID=ev_admin
|
||||
BNFEXPRESS_AI_CLIENT_SECRET=
|
||||
|
||||
@@ -25,6 +25,18 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ''
|
||||
CACHE_STORE: array
|
||||
CACHE_DRIVER: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -56,12 +68,21 @@ jobs:
|
||||
- name: Generate app key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Install postgresql-client
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y postgresql-client
|
||||
|
||||
- name: Wait for Postgres
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
until pg_isready -h postgres -p 5432 -U root; do
|
||||
echo "Waiting for postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run migrations
|
||||
run: php artisan migrate --force
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ''
|
||||
run: php artisan test --compact
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "modules/ai-agent",
|
||||
"description": "",
|
||||
"type": "library",
|
||||
"version": "1.0",
|
||||
"license": "proprietary",
|
||||
"require": {},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\AiAgent\\": "src/",
|
||||
"Modules\\AiAgent\\Tests\\": "tests/",
|
||||
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section heading="Active Instruction">
|
||||
@if ($active)
|
||||
<pre class="whitespace-pre-wrap text-sm">{{ $active['content'] ?? '' }}</pre>
|
||||
@elseif ($activeError)
|
||||
<p class="text-sm text-danger-600">Could not load the active instruction: {{ $activeError }}</p>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">No active instruction.</p>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section heading="Sync Status">
|
||||
@if ($syncJobId)
|
||||
<div wire:poll.2s="pollSyncStatus" class="text-sm">
|
||||
Job {{ $syncJobId }}: {{ $syncStatus }}...
|
||||
</div>
|
||||
@elseif ($syncResult)
|
||||
<pre class="whitespace-pre-wrap text-sm">{{ json_encode($syncResult, JSON_PRETTY_PRINT) }}</pre>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">No sync running.</p>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<p class="text-sm text-danger-600">Could not load this transcript: {{ $message }}</p>
|
||||
@@ -0,0 +1,34 @@
|
||||
@php
|
||||
$labels = [
|
||||
'user' => 'User',
|
||||
'bnfexpress_ev_agent' => 'Assistant',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="max-h-[32rem] space-y-3 overflow-y-auto">
|
||||
@forelse (($transcript['messages'] ?? []) as $message)
|
||||
@php
|
||||
$author = $message['author'] ?? 'unknown';
|
||||
$isUser = $author === 'user';
|
||||
@endphp
|
||||
<div @class([
|
||||
'max-w-[85%] rounded-lg border p-3',
|
||||
'ms-auto border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950' => $isUser,
|
||||
'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800' => ! $isUser,
|
||||
])>
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<span class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ $labels[$author] ?? $author }}
|
||||
</span>
|
||||
@if (isset($message['timestamp']))
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ \Illuminate\Support\Carbon::createFromTimestamp($message['timestamp'])->format('M j, Y g:i A') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm">{{ $message['text'] ?? '' }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500">No messages in this session.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class AiAgentPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'ai-agent';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\AiAgent\Filament\Pages',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Concerns;
|
||||
|
||||
use Filament\Notifications\Notification;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
|
||||
/**
|
||||
* Shared try/call/notify wrapper for bnfexpress admin API calls triggered
|
||||
* from a Filament action — every mutating action across the AI Agent pages
|
||||
* (create/update/delete/publish/activate) follows this same shape.
|
||||
*/
|
||||
trait HandlesBnfexpressErrors
|
||||
{
|
||||
/**
|
||||
* @param callable(): void $callback
|
||||
*/
|
||||
protected function callBnfexpress(callable $callback, string $successTitle, string $failureTitle): void
|
||||
{
|
||||
try {
|
||||
$callback();
|
||||
|
||||
Notification::make()->title($successTitle)->success()->send();
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same shape as callBnfexpress(), but for calls whose success notification
|
||||
* needs the response (e.g. a "{created} created, {skipped} skipped" body) —
|
||||
* $onSuccess builds/sends its own Notification from $callback()'s return value.
|
||||
*
|
||||
* @param callable(): mixed $callback
|
||||
* @param callable(mixed): void $onSuccess
|
||||
*/
|
||||
protected function callBnfexpressForResult(callable $callback, callable $onSuccess, string $failureTitle): void
|
||||
{
|
||||
try {
|
||||
$onSuccess($callback());
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Concerns;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Bridges a bnfexpress list response (a bare JSON array — confirmed live via
|
||||
* `php artisan bnfexpress:smoke-test` for FAQs/instructions, and the same
|
||||
* shape for suggestions/misses per their `response_model=list[...]`) into a
|
||||
* LengthAwarePaginator for Table::records(). bnfexpress reports no total
|
||||
* count, so this falls back to a "there might be one more page" heuristic —
|
||||
* also tolerates a {total, <$itemsKey>} envelope in case that ever changes.
|
||||
*/
|
||||
trait PaginatesBnfexpressLists
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function paginateBareList(array $result, string $itemsKey, string $recordKey, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
|
||||
|
||||
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($items)->mapWithKeys(fn (array $item): array => [$item[$recordKey] => $item]),
|
||||
total: $total,
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Version history + publish/rollback for the EV agent's system prompt.
|
||||
* Needs more than a bare table (an "active version" banner above the
|
||||
* history), so — like ManageAppSettings — it renders through a custom view
|
||||
* rather than relying purely on Filament's generated table layout.
|
||||
*/
|
||||
class ManageAgentInstructions extends Page implements HasTable
|
||||
{
|
||||
use HandlesBnfexpressErrors;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'Agent Instructions';
|
||||
|
||||
protected static ?string $title = 'Agent Instructions';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.manage-agent-instructions';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $active = null;
|
||||
|
||||
public ?string $activeError = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshActive();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listInstructions(
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
$items = $result['instructions'] ?? (array_is_list($result) ? $result : []);
|
||||
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
|
||||
total: $total,
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id'),
|
||||
IconColumn::make('is_active')->boolean(),
|
||||
TextColumn::make('content')->limit(80)->wrap(),
|
||||
TextColumn::make('created_at')->dateTime(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('activate')
|
||||
->label('Activate')
|
||||
->icon(Heroicon::OutlinedArrowUturnLeft)
|
||||
->visible(fn (array $record): bool => ! ($record['is_active'] ?? false))
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->activateInstruction($record['id']),
|
||||
successTitle: 'Instruction activated',
|
||||
failureTitle: 'Failed to activate instruction',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
$this->refreshActive();
|
||||
}),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('publish')
|
||||
->label('Publish New Version')
|
||||
->icon(Heroicon::OutlinedPlusCircle)
|
||||
->schema([
|
||||
Textarea::make('content')
|
||||
->required()
|
||||
->rows(10),
|
||||
Toggle::make('activate')
|
||||
->label('Activate immediately')
|
||||
->default(true)
|
||||
->helperText('Deactivates the current active version automatically.'),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->publishInstruction($data['content'], $data['activate']),
|
||||
successTitle: 'New instruction version published',
|
||||
failureTitle: 'Failed to publish instruction',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
$this->refreshActive();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private function refreshActive(): void
|
||||
{
|
||||
try {
|
||||
$this->active = app(BnfexpressAdminClient::class)->getActiveInstruction();
|
||||
$this->activeError = null;
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
$this->active = null;
|
||||
$this->activeError = $exception->getMessage();
|
||||
|
||||
Notification::make()
|
||||
->title('Could not load the active instruction')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Manage EV FAQs stored by bnfexpress — data isn't Eloquent-backed, so the
|
||||
* table is fed via Table::records() (Filament's documented "custom data"
|
||||
* mechanism) rather than a query, and row/header actions use plain
|
||||
* Filament\Actions\Action instead of EditAction/DeleteAction (which assume
|
||||
* a Model).
|
||||
*/
|
||||
class ManageFaqs extends Page implements HasTable
|
||||
{
|
||||
use HandlesBnfexpressErrors;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQuestionMarkCircle;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'EV FAQs';
|
||||
|
||||
protected static ?string $title = 'EV FAQs';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.manage-faqs';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (?string $search, array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listFaqs(
|
||||
q: $search,
|
||||
search: $filters['search_mode']['value'] ?? 'normal',
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
return $this->paginate($result, 'faqs', $page, $recordsPerPage);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id'),
|
||||
TextColumn::make('content')
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
TextColumn::make('metadata')
|
||||
->formatStateUsing(fn (mixed $state): string => json_encode($state ?? [], JSON_THROW_ON_ERROR))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->searchable()
|
||||
->filters([
|
||||
SelectFilter::make('search_mode')
|
||||
->label('Search mode')
|
||||
->options([
|
||||
'normal' => 'Normal (substring)',
|
||||
'semantic' => 'Semantic (meaning-based)',
|
||||
])
|
||||
->default('normal'),
|
||||
])
|
||||
->recordActions([
|
||||
$this->editAction(),
|
||||
$this->deleteAction(),
|
||||
])
|
||||
->headerActions([
|
||||
$this->createAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createAction(): Action
|
||||
{
|
||||
return Action::make('create')
|
||||
->label('New FAQ')
|
||||
->icon(Heroicon::OutlinedPlus)
|
||||
->schema($this->formSchema())
|
||||
->action(function (array $data): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->createFaq($data['content'], $data['metadata'] ?? []),
|
||||
successTitle: 'FAQ created',
|
||||
failureTitle: 'Failed to create FAQ',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function editAction(): Action
|
||||
{
|
||||
return Action::make('edit')
|
||||
->icon(Heroicon::OutlinedPencilSquare)
|
||||
->fillForm(fn (array $record): array => $record)
|
||||
->schema($this->formSchema())
|
||||
->action(function (array $data, array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->updateFaq($record['id'], $data['content'], $data['metadata'] ?? []),
|
||||
successTitle: 'FAQ updated',
|
||||
failureTitle: 'Failed to update FAQ',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function deleteAction(): Action
|
||||
{
|
||||
return Action::make('delete')
|
||||
->color('danger')
|
||||
->icon(Heroicon::OutlinedTrash)
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->deleteFaq($record['id']),
|
||||
successTitle: 'FAQ deleted',
|
||||
failureTitle: 'Failed to delete FAQ',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Component>
|
||||
*/
|
||||
protected function formSchema(): array
|
||||
{
|
||||
return [
|
||||
Textarea::make('content')
|
||||
->required()
|
||||
->rows(4),
|
||||
KeyValue::make('metadata'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* bnfexpress's list/faqs and list/agent-instructions endpoints return a
|
||||
* bare JSON array (confirmed live via `php artisan bnfexpress:smoke-test`),
|
||||
* with no total/limit/offset envelope — so there's no real total to
|
||||
* report, and this falls back to a "there might be one more page"
|
||||
* heuristic (also tolerates a {total, <$itemsKey>} envelope, in case
|
||||
* that ever changes).
|
||||
*
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function paginate(array $result, string $itemsKey, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
|
||||
|
||||
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
|
||||
total: $total,
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||
use Modules\AiAgent\Filament\Concerns\PaginatesBnfexpressLists;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Browse bnfexpress's "suggestion misses" — queries typed by real users that
|
||||
* no suggestion tier answered — and either dismiss them (noise) or promote
|
||||
* a batch straight into the suggestions bank. Read-mostly: no create/edit,
|
||||
* these rows are only ever produced by bnfexpress's own suggest pipeline.
|
||||
*/
|
||||
class ManageSuggestionMisses extends Page implements HasTable
|
||||
{
|
||||
use HandlesBnfexpressErrors;
|
||||
use InteractsWithTable;
|
||||
use PaginatesBnfexpressLists;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlassCircle;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'Suggestion Misses';
|
||||
|
||||
protected static ?string $title = 'Suggestion Misses';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.manage-suggestion-misses';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$wasUsed = $filters['was_used']['value'] ?? null;
|
||||
|
||||
$result = app(BnfexpressAdminClient::class)->listSuggestionMisses(
|
||||
wasUsed: $wasUsed === null || $wasUsed === '' ? null : (bool) $wasUsed,
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
return $this->paginateBareList($result, 'misses', 'id', $page, $recordsPerPage);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id'),
|
||||
TextColumn::make('text_norm')
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
TextColumn::make('lang')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('syllables')
|
||||
->placeholder('—'),
|
||||
IconColumn::make('was_used')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
// SelectFilter (not TernaryFilter) so the value lands in
|
||||
// $filters['was_used']['value'] predictably — TernaryFilter's
|
||||
// internal field key isn't documented for the custom-data path.
|
||||
->filters([
|
||||
SelectFilter::make('was_used')
|
||||
->label('Used?')
|
||||
->options(['1' => 'Used', '0' => 'Not used']),
|
||||
])
|
||||
->recordActions([
|
||||
$this->dismissAction(),
|
||||
])
|
||||
->toolbarActions([
|
||||
$this->promoteBulkAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function dismissAction(): Action
|
||||
{
|
||||
return Action::make('dismiss')
|
||||
->color('danger')
|
||||
->icon(Heroicon::OutlinedTrash)
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->dismissSuggestionMiss($record['id']),
|
||||
successTitle: 'Miss dismissed',
|
||||
failureTitle: 'Failed to dismiss miss',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function promoteBulkAction(): BulkAction
|
||||
{
|
||||
return BulkAction::make('promote')
|
||||
->label('Promote Selected')
|
||||
->icon(Heroicon::OutlinedArrowUp)
|
||||
->fetchSelectedRecords(false)
|
||||
->schema([
|
||||
TextInput::make('lang')
|
||||
->label('Language override')
|
||||
->helperText("Applied to every selected miss; leave blank to keep each one's own language.")
|
||||
->maxLength(10),
|
||||
TextInput::make('intent'),
|
||||
])
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (array $data, Collection $records): void {
|
||||
// Same caveat as ManageSuggestions' deleteSelected — the collection
|
||||
// holds full row arrays, not just keys, for a custom-data table.
|
||||
$this->callBnfexpressForResult(
|
||||
fn () => app(BnfexpressAdminClient::class)->promoteSuggestionMisses(
|
||||
$records->keys()->all(),
|
||||
$data['lang'] ?: null,
|
||||
$data['intent'] ?: null,
|
||||
),
|
||||
function (array $result): void {
|
||||
Notification::make()
|
||||
->title("{$result['created']} promoted, {$result['skipped']} skipped")
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->resetTable();
|
||||
},
|
||||
failureTitle: 'Failed to promote suggestion misses',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||
use Modules\AiAgent\Filament\Concerns\PaginatesBnfexpressLists;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Manage bnfexpress's autocomplete "suggestions" phrase bank: CRUD, batch
|
||||
* import, batch delete, and syncing embeddings — folded into one page per
|
||||
* the sync-alongside-CRUD layout (rather than a separate sync-only page).
|
||||
* Data isn't Eloquent-backed, so the table is fed via Table::records() and
|
||||
* mutating actions use plain Filament\Actions\Action, same as ManageFaqs.
|
||||
*/
|
||||
class ManageSuggestions extends Page implements HasTable
|
||||
{
|
||||
use HandlesBnfexpressErrors;
|
||||
use InteractsWithTable;
|
||||
use PaginatesBnfexpressLists;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'Suggestions';
|
||||
|
||||
protected static ?string $title = 'Suggestions';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.manage-suggestions';
|
||||
|
||||
public ?string $syncJobId = null;
|
||||
|
||||
public ?string $syncStatus = null;
|
||||
|
||||
/**
|
||||
* @var mixed
|
||||
*/
|
||||
public $syncResult = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (?string $search, int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listSuggestions(
|
||||
q: $search,
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
return $this->paginateBareList($result, 'suggestions', 'id', $page, $recordsPerPage);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id'),
|
||||
TextColumn::make('text_display')
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
TextColumn::make('lang'),
|
||||
TextColumn::make('intent')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('weight')
|
||||
->sortable(false),
|
||||
TextColumn::make('source'),
|
||||
TextColumn::make('synced_at')
|
||||
->dateTime()
|
||||
->placeholder('Never')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime(),
|
||||
])
|
||||
->searchable()
|
||||
->recordActions([
|
||||
$this->editAction(),
|
||||
$this->deleteAction(),
|
||||
$this->syncEmbeddingAction(),
|
||||
$this->deleteEmbeddingAction(),
|
||||
])
|
||||
->toolbarActions([
|
||||
$this->deleteSelectedBulkAction(),
|
||||
])
|
||||
->headerActions([
|
||||
$this->createAction(),
|
||||
$this->createManyAction(),
|
||||
$this->syncAction(),
|
||||
$this->reloadIndexAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createAction(): Action
|
||||
{
|
||||
return Action::make('create')
|
||||
->label('New Suggestion')
|
||||
->icon(Heroicon::OutlinedPlus)
|
||||
->schema($this->formSchema())
|
||||
->action(function (array $data): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->createSuggestion(
|
||||
$data['text_display'],
|
||||
$data['lang'],
|
||||
$data['intent'] ?: null,
|
||||
(int) ($data['weight'] ?? 0),
|
||||
$data['source'] ?: 'admin',
|
||||
),
|
||||
successTitle: 'Suggestion created',
|
||||
failureTitle: 'Failed to create suggestion',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function createManyAction(): Action
|
||||
{
|
||||
return Action::make('createMany')
|
||||
->label('Create Many')
|
||||
->icon(Heroicon::OutlinedQueueList)
|
||||
->schema([
|
||||
// No source field here — bnfexpress's batch endpoint always tags
|
||||
// these rows source: "mined" server-side (it's a thin wrapper
|
||||
// around the same promote() misses-promotion uses), regardless
|
||||
// of what's sent, so exposing a picker would be misleading.
|
||||
Text::make('Created rows are tagged source: mined by bnfexpress.')
|
||||
->color('gray'),
|
||||
Textarea::make('items_raw')
|
||||
->label('Phrases (one per line)')
|
||||
->required()
|
||||
->rows(8),
|
||||
TextInput::make('lang')
|
||||
->required()
|
||||
->maxLength(10),
|
||||
TextInput::make('intent'),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$items = collect(preg_split('/\r\n|\r|\n/', (string) $data['items_raw']))
|
||||
->map(fn (string $line): string => trim($line))
|
||||
->filter()
|
||||
->map(fn (string $text): array => array_filter([
|
||||
'text' => $text,
|
||||
'lang' => $data['lang'],
|
||||
'intent' => $data['intent'] ?: null,
|
||||
], fn (mixed $value): bool => $value !== null))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->callBnfexpressForResult(
|
||||
fn () => app(BnfexpressAdminClient::class)->batchCreateSuggestions($items),
|
||||
function (array $result): void {
|
||||
Notification::make()
|
||||
->title("{$result['created']} created, {$result['skipped']} skipped")
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->resetTable();
|
||||
},
|
||||
failureTitle: 'Failed to create suggestions',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected function editAction(): Action
|
||||
{
|
||||
return Action::make('edit')
|
||||
->icon(Heroicon::OutlinedPencilSquare)
|
||||
->fillForm(fn (array $record): array => $record)
|
||||
->schema($this->formSchema())
|
||||
->action(function (array $data, array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->updateSuggestion(
|
||||
$record['id'],
|
||||
$data['text_display'],
|
||||
$data['lang'],
|
||||
$data['intent'] ?: null,
|
||||
(int) ($data['weight'] ?? 0),
|
||||
$data['source'] ?: null,
|
||||
),
|
||||
successTitle: 'Suggestion updated',
|
||||
failureTitle: 'Failed to update suggestion',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function deleteAction(): Action
|
||||
{
|
||||
return Action::make('delete')
|
||||
->color('danger')
|
||||
->icon(Heroicon::OutlinedTrash)
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->deleteSuggestion($record['id']),
|
||||
successTitle: 'Suggestion deleted',
|
||||
failureTitle: 'Failed to delete suggestion',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function deleteSelectedBulkAction(): BulkAction
|
||||
{
|
||||
return BulkAction::make('deleteSelected')
|
||||
->label('Delete Selected')
|
||||
->color('danger')
|
||||
->icon(Heroicon::OutlinedTrash)
|
||||
->requiresConfirmation()
|
||||
->fetchSelectedRecords(false)
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (Collection $records): void {
|
||||
// fetchSelectedRecords(false) still resolves full row arrays for a
|
||||
// custom-data table (there's no cheap ID-only path like an Eloquent
|
||||
// query) — the record keys (our suggestion ids) are what's wanted here.
|
||||
$this->callBnfexpressForResult(
|
||||
fn () => app(BnfexpressAdminClient::class)->batchDeleteSuggestions($records->keys()->all()),
|
||||
function (array $result): void {
|
||||
Notification::make()
|
||||
->title("{$result['deleted']} deleted, {$result['skipped']} skipped")
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->resetTable();
|
||||
},
|
||||
failureTitle: 'Failed to delete suggestions',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected function syncEmbeddingAction(): Action
|
||||
{
|
||||
return Action::make('syncEmbedding')
|
||||
->label('Sync Embedding')
|
||||
->icon(Heroicon::OutlinedArrowPath)
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->syncOneSuggestion($record['id']),
|
||||
successTitle: 'Embedding synced',
|
||||
failureTitle: 'Failed to sync embedding',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function deleteEmbeddingAction(): Action
|
||||
{
|
||||
return Action::make('deleteEmbedding')
|
||||
->label('Delete Embedding')
|
||||
->color('danger')
|
||||
->icon(Heroicon::OutlinedXCircle)
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->deleteSuggestionEmbedding($record['id']),
|
||||
successTitle: 'Embedding deleted',
|
||||
failureTitle: 'Failed to delete embedding',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks off a full re-embed job and starts polling for it (pollSyncStatus(),
|
||||
* driven by wire:poll in the view) rather than notifying immediately —
|
||||
* the real outcome only lands once the job finishes.
|
||||
*/
|
||||
protected function syncAction(): Action
|
||||
{
|
||||
return Action::make('sync')
|
||||
->label('Sync to Chroma')
|
||||
->icon(Heroicon::OutlinedArrowPath)
|
||||
->action(function (): void {
|
||||
try {
|
||||
$result = app(BnfexpressAdminClient::class)->syncSuggestions();
|
||||
$this->syncJobId = $result['job_id'] ?? null;
|
||||
$this->syncStatus = 'queued';
|
||||
$this->syncResult = null;
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
Notification::make()
|
||||
->title('Failed to start sync')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function reloadIndexAction(): Action
|
||||
{
|
||||
return Action::make('reloadIndex')
|
||||
->label('Reload Index')
|
||||
->icon(Heroicon::OutlinedArrowPath)
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->reloadSuggestionIndex(),
|
||||
successTitle: 'Index reloaded',
|
||||
failureTitle: 'Failed to reload index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a sync job's status (wire:poll.2s, see the view). On "finished",
|
||||
* chains a reload-index call — same two-step flow shweai_backend's admin
|
||||
* JS does (POST sync-chroma → poll → POST reload-index) — and refreshes
|
||||
* the table. On "failed", notifies and stops polling.
|
||||
*/
|
||||
public function pollSyncStatus(): void
|
||||
{
|
||||
if ($this->syncJobId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = app(BnfexpressAdminClient::class)->getSuggestionSyncStatus($this->syncJobId);
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
$this->syncJobId = null;
|
||||
Notification::make()
|
||||
->title('Failed to check sync status')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->syncStatus = $status['status'] ?? null;
|
||||
|
||||
if ($this->syncStatus === 'finished') {
|
||||
$this->syncResult = $status['result'] ?? null;
|
||||
$this->syncJobId = null;
|
||||
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->reloadSuggestionIndex(),
|
||||
successTitle: 'Sync completed and index reloaded',
|
||||
failureTitle: 'Sync finished but reloading the index failed',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
} elseif ($this->syncStatus === 'failed') {
|
||||
$this->syncJobId = null;
|
||||
|
||||
Notification::make()
|
||||
->title('Sync failed')
|
||||
->body(is_string($status['result'] ?? null) ? $status['result'] : 'Unknown error.')
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
// else: still queued/running — the view keeps polling.
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Component>
|
||||
*/
|
||||
protected function formSchema(): array
|
||||
{
|
||||
return [
|
||||
Textarea::make('text_display')
|
||||
->required()
|
||||
->rows(3),
|
||||
TextInput::make('lang')
|
||||
->required()
|
||||
->maxLength(10),
|
||||
TextInput::make('intent'),
|
||||
TextInput::make('weight')
|
||||
->numeric()
|
||||
->default(0),
|
||||
// Fixed choices rather than free text (matching shweai_backend's
|
||||
// seed/mined dropdown) — 'admin' is bnfexpress's own default for
|
||||
// a CRUD-created row (SuggestionAdminCreate.source), 'mined' is
|
||||
// what promote() tags a row with. 'seed' isn't used by any
|
||||
// bnfexpress code path today (unlike the other two, which are
|
||||
// hardcoded/defaulted server-side) — source is just a free string
|
||||
// there, so this is offered for admins bootstrapping initial
|
||||
// phrases who want that distinct from an ad-hoc manual entry,
|
||||
// same convention as shweai_backend's own seed/mined dropdown.
|
||||
Select::make('source')
|
||||
->options([
|
||||
'admin' => 'Admin (manual entry)',
|
||||
'seed' => 'Seed (initial/bootstrap data)',
|
||||
'mined' => 'Mined (promoted from a miss)',
|
||||
])
|
||||
->default('admin')
|
||||
->native(false)
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-only browse of all users' EV chat sessions. No create/edit/delete —
|
||||
* this is a support/QA tool, not a data-management screen.
|
||||
*/
|
||||
class ViewEvChatHistory extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'EV Chat History';
|
||||
|
||||
protected static ?string $title = 'EV Chat History';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.view-ev-chat-history';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listSessions(
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
$sessions = $result['sessions'] ?? [];
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($sessions)->mapWithKeys(
|
||||
fn (array $session): array => ["{$session['user_id']}:{$session['session_id']}" => $session]
|
||||
),
|
||||
total: $result['total'] ?? count($sessions),
|
||||
perPage: $result['limit'] ?? $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('session_id')
|
||||
->limit(20)
|
||||
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
|
||||
TextColumn::make('user_id')
|
||||
->limit(20)
|
||||
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
|
||||
TextColumn::make('title')->limit(50),
|
||||
TextColumn::make('last_message')->limit(80)->wrap(),
|
||||
TextColumn::make('updated_at')->dateTime(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('view')
|
||||
->label('View Transcript')
|
||||
->icon(Heroicon::OutlinedEye)
|
||||
->modalHeading(fn (array $record): string => "Transcript — {$record['session_id']}")
|
||||
->modalContent(fn (array $record): View => $this->transcriptView($record))
|
||||
->modalWidth('2xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function tooltipIfTruncated(TextColumn $column): ?string
|
||||
{
|
||||
$state = (string) $column->getState();
|
||||
|
||||
return strlen($state) > $column->getCharacterLimit() ? $state : null;
|
||||
}
|
||||
|
||||
private function transcriptView(array $record): View
|
||||
{
|
||||
try {
|
||||
$transcript = app(BnfexpressAdminClient::class)->getSessionTranscript($record['user_id'], $record['session_id']);
|
||||
|
||||
return view('ai-agent::filament.pages.partials.transcript', ['transcript' => $transcript]);
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
Notification::make()
|
||||
->title('Could not load transcript')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return view('ai-agent::filament.pages.partials.transcript-error', ['message' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AiAgentServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void {}
|
||||
|
||||
public function boot(): void {}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageAgentInstructions;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageAgentInstructions::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders the active instruction and version history', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'You are the EV assistant.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||
'total' => 1,
|
||||
'instructions' => [['id' => 2, 'is_active' => true, 'content' => 'You are the EV assistant.', 'created_at' => now()->toIso8601String()]],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->assertOk()
|
||||
->assertSee('You are the EV assistant.')
|
||||
->loadTable()
|
||||
->assertSee('You are the EV assistant.');
|
||||
});
|
||||
|
||||
test('publishing a new version calls publishInstruction and shows a success notification', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 3, 'content' => 'New instructions.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 3, 'content' => 'New instructions.', 'is_active' => true], 201),
|
||||
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||
},
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||
->assertNotified('New instruction version published');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
|
||||
&& str_contains($request->url(), '/admin/agent-instructions')
|
||||
&& ! str_contains($request->url(), '/active')
|
||||
&& $request['content'] === 'New instructions.'
|
||||
&& $request['activate'] === true);
|
||||
});
|
||||
|
||||
test('a failed publish surfaces the gateway detail message', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 1, 'content' => 'Old instructions.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||
},
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||
->assertNotified('Failed to publish instruction');
|
||||
});
|
||||
|
||||
test('activate is hidden on the already-active row and visible on others', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'Current.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||
'total' => 2,
|
||||
'instructions' => [
|
||||
['id' => 2, 'is_active' => true, 'content' => 'Current.', 'created_at' => now()->toIso8601String()],
|
||||
['id' => 1, 'is_active' => false, 'content' => 'Older.', 'created_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->assertTableActionHidden('activate', 2)
|
||||
->assertTableActionVisible('activate', 1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageFaqs;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageFaqs::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders and lists faqs from the client', function () {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response([
|
||||
'total' => 1,
|
||||
'faqs' => [
|
||||
['id' => 1, 'content' => 'How do I charge my EV?', 'metadata' => []],
|
||||
],
|
||||
])]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('How do I charge my EV?');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/faqs') && $request->method() === 'GET');
|
||||
});
|
||||
|
||||
test('creating a faq calls createFaq and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 1, 'content' => 'New FAQ', 'metadata' => []], 201),
|
||||
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||
->assertNotified('FAQ created');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request['content'] === 'New FAQ');
|
||||
});
|
||||
|
||||
test('a failed create surfaces the gateway detail message', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||
->assertNotified('Failed to create FAQ');
|
||||
});
|
||||
|
||||
test('deleting a faq calls deleteFaq and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'DELETE' => Http::response(['deleted' => true]),
|
||||
default => Http::response(['total' => 1, 'faqs' => [['id' => 1, 'content' => 'To delete', 'metadata' => []]]]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('delete', 1)
|
||||
->assertNotified('FAQ deleted');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/faqs/1'));
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageSuggestionMisses;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageSuggestionMisses::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders and lists misses from the client', function () {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response([
|
||||
['id' => 1, 'text_norm' => 'ev charging cost', 'lang' => 'en', 'syllables' => 3, 'was_used' => false, 'created_at' => now()->toIso8601String()],
|
||||
])]);
|
||||
|
||||
Livewire::test(ManageSuggestionMisses::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('ev charging cost');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestion-misses') && $request->method() === 'GET');
|
||||
});
|
||||
|
||||
test('dismiss calls dismissSuggestionMiss and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'DELETE' => Http::response([]),
|
||||
default => Http::response([
|
||||
['id' => 1, 'text_norm' => 'to dismiss', 'lang' => 'en', 'syllables' => 2, 'was_used' => false, 'created_at' => now()->toIso8601String()],
|
||||
]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestionMisses::class)
|
||||
->loadTable()
|
||||
->callTableAction('dismiss', 1)
|
||||
->assertNotified('Miss dismissed');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestion-misses/1'));
|
||||
});
|
||||
|
||||
test('a failed dismiss surfaces the gateway detail message', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'DELETE' => Http::response(['detail' => 'Suggestion miss not found.'], 404),
|
||||
default => Http::response([
|
||||
['id' => 1, 'text_norm' => 'to dismiss', 'lang' => 'en', 'syllables' => 2, 'was_used' => false, 'created_at' => now()->toIso8601String()],
|
||||
]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestionMisses::class)
|
||||
->loadTable()
|
||||
->callTableAction('dismiss', 1)
|
||||
->assertNotified('Failed to dismiss miss');
|
||||
});
|
||||
|
||||
test('promote bulk action calls promoteSuggestionMisses with the selected ids and shows the result', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
|
||||
str_contains($request->url(), '/admin/suggestion-misses/promote') => Http::response(['created' => 2, 'skipped' => 0, 'trie_rebuilt' => true]),
|
||||
default => Http::response([
|
||||
['id' => 1, 'text_norm' => 'a', 'lang' => 'en', 'syllables' => 1, 'was_used' => false, 'created_at' => now()->toIso8601String()],
|
||||
['id' => 2, 'text_norm' => 'b', 'lang' => 'en', 'syllables' => 1, 'was_used' => false, 'created_at' => now()->toIso8601String()],
|
||||
]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestionMisses::class)
|
||||
->loadTable()
|
||||
->callTableBulkAction('promote', [1, 2], data: ['lang' => 'my', 'intent' => null])
|
||||
->assertNotified('2 promoted, 0 skipped');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestion-misses/promote')
|
||||
&& $request['miss_ids'] === [1, 2]
|
||||
&& $request['lang'] === 'my');
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageSuggestions;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageSuggestions::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders and lists suggestions from the client', function () {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response([
|
||||
['id' => 1, 'text_display' => 'How do I charge my EV?', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
|
||||
])]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('How do I charge my EV?');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions') && $request->method() === 'GET');
|
||||
});
|
||||
|
||||
test('creating a suggestion calls createSuggestion and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 1], 201),
|
||||
default => Http::response([]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['text_display' => 'New phrase', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'admin'])
|
||||
->assertNotified('Suggestion created');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
|
||||
&& $request->url() === 'https://bnfexpress.test/admin/suggestions'
|
||||
&& $request['text_display'] === 'New phrase');
|
||||
});
|
||||
|
||||
test('a failed create surfaces the gateway detail message', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'text_display is required.'], 422),
|
||||
default => Http::response([]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['text_display' => 'New phrase', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'admin'])
|
||||
->assertNotified('Failed to create suggestion');
|
||||
});
|
||||
|
||||
test('createMany splits pasted lines into batch items and shows the created/skipped result', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
|
||||
str_contains($request->url(), '/admin/suggestions/batch') => Http::response(['created' => 2, 'skipped' => 0, 'trie_rebuilt' => true]),
|
||||
default => Http::response([]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('createMany', data: ['items_raw' => "First phrase\nSecond phrase\n\n", 'lang' => 'en', 'intent' => null])
|
||||
->assertNotified('2 created, 0 skipped');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/batch')
|
||||
&& $request['items'] === [
|
||||
['text' => 'First phrase', 'lang' => 'en'],
|
||||
['text' => 'Second phrase', 'lang' => 'en'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('deleting a suggestion calls deleteSuggestion and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'DELETE' => Http::response([]),
|
||||
default => Http::response([
|
||||
['id' => 1, 'text_display' => 'To delete', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
|
||||
]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('delete', 1)
|
||||
->assertNotified('Suggestion deleted');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestions/1'));
|
||||
});
|
||||
|
||||
test('deleteSelected bulk action calls batchDeleteSuggestions with just the selected ids', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
|
||||
$request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestions/batch') => Http::response(['deleted' => 2, 'skipped' => 0]),
|
||||
default => Http::response([
|
||||
['id' => 1, 'text_display' => 'A', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
|
||||
['id' => 2, 'text_display' => 'B', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
|
||||
]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableBulkAction('deleteSelected', [1, 2])
|
||||
->assertNotified('2 deleted, 0 skipped');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE'
|
||||
&& str_contains($request->url(), '/admin/suggestions/batch')
|
||||
&& $request['ids'] === [1, 2]);
|
||||
});
|
||||
|
||||
test('sync sets job state and polling chains a reload-index call on finished', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
|
||||
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'finished', 'result' => ['synced' => 3]]),
|
||||
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202),
|
||||
str_contains($request->url(), '/reload-index') => Http::response(['trie_rebuilt' => true]),
|
||||
default => Http::response([]),
|
||||
}]);
|
||||
|
||||
$test = Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('sync')
|
||||
->assertSet('syncJobId', 'job-1')
|
||||
->assertSet('syncStatus', 'queued');
|
||||
|
||||
$test->call('pollSyncStatus')
|
||||
->assertSet('syncJobId', null)
|
||||
->assertSet('syncStatus', 'finished')
|
||||
->assertNotified('Sync completed and index reloaded');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/reload-index'));
|
||||
});
|
||||
|
||||
test('a failed sync job notifies danger and stops polling', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
|
||||
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'failed', 'result' => null]),
|
||||
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202),
|
||||
default => Http::response([]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('sync')
|
||||
->call('pollSyncStatus')
|
||||
->assertSet('syncJobId', null)
|
||||
->assertNotified('Sync failed');
|
||||
});
|
||||
|
||||
test('reloadIndex calls reloadSuggestionIndex directly', function () {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['trie_rebuilt' => true])]);
|
||||
|
||||
Livewire::test(ManageSuggestions::class)
|
||||
->loadTable()
|
||||
->callTableAction('reloadIndex')
|
||||
->assertNotified('Index reloaded');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/reload-index'));
|
||||
});
|
||||
|
||||
test('syncEmbedding and deleteEmbedding row actions call the right per-id endpoint', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
|
||||
str_contains($request->url(), '/1/sync-chroma') => Http::response(['synced' => true]),
|
||||
str_contains($request->url(), '/1/chroma') => Http::response([]),
|
||||
default => Http::response([
|
||||
['id' => 1, 'text_display' => 'A', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
|
||||
]),
|
||||
}]);
|
||||
|
||||
$test = Livewire::test(ManageSuggestions::class)->loadTable();
|
||||
|
||||
$test->callTableAction('syncEmbedding', 1)->assertNotified('Embedding synced');
|
||||
$test->callTableAction('deleteEmbedding', 1)->assertNotified('Embedding deleted');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/sync-chroma') && $request->method() === 'POST');
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/chroma') && $request->method() === 'DELETE');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ViewEvChatHistory;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ViewEvChatHistory::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it lists sessions from the client', function () {
|
||||
Http::fake(['bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
])]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('Charging question');
|
||||
});
|
||||
|
||||
test('viewing a transcript shows its messages', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response([
|
||||
'session_id' => 'sess-1',
|
||||
'user_id' => 'user-1',
|
||||
'messages' => [
|
||||
['author' => 'user', 'text' => 'How do I charge my EV?', 'timestamp' => now()->timestamp],
|
||||
['author' => 'bnfexpress_ev_agent', 'text' => 'Plug it in at a station.', 'timestamp' => now()->timestamp],
|
||||
],
|
||||
]),
|
||||
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->loadTable()
|
||||
->mountTableAction('view', 'user-1:sess-1')
|
||||
->assertMountedActionModalSee('Plug it in at a station.');
|
||||
});
|
||||
|
||||
test('a failed transcript fetch surfaces the gateway detail message', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response(['detail' => 'Session not found.'], 404),
|
||||
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->loadTable()
|
||||
->mountTableAction('view', 'user-1:sess-1')
|
||||
->assertMountedActionModalSee('Session not found.')
|
||||
->assertNotified('Could not load transcript');
|
||||
});
|
||||
@@ -29,6 +29,8 @@ class BookingFactory extends Factory
|
||||
'user_id' => null,
|
||||
'openid' => null,
|
||||
'ev_route_id' => EvRoute::factory(),
|
||||
'linked_booking_id' => null,
|
||||
'is_return_leg' => false,
|
||||
'departure_time_slot_id' => DepartureTimeSlot::factory(),
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'passenger_name' => $this->faker->name(),
|
||||
@@ -41,8 +43,6 @@ class BookingFactory extends Factory
|
||||
'dropoff_lng' => null,
|
||||
'price' => $this->faker->randomFloat(2, 5000, 50000),
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => false,
|
||||
'return_travel_date' => null,
|
||||
'created_by_channel' => BookingChannel::MiniApp,
|
||||
'driver_name' => null,
|
||||
'driver_phone' => null,
|
||||
|
||||
+32
@@ -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
|
||||
{
|
||||
/**
|
||||
* 'notes' — customer-supplied, submitted via the booking create API
|
||||
* endpoint (StoreBookingRequest). 'remark' — staff-only, set from the
|
||||
* admin panel (SetRemarkTableAction); never exposed on the customer
|
||||
* BookingResource. Both nullable, free text.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->text('notes')->nullable();
|
||||
$table->text('remark')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropColumn(['notes', 'remark']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Round trip is redesigned as two linked one-way Booking rows (outbound
|
||||
* + return) rather than a flag + a lone return date on a single row —
|
||||
* the return leg needs its own route/time-slot/price/driver-vehicle
|
||||
* assignment, since it may run with a different vehicle than the
|
||||
* outbound leg (domain.md §2b). `is_round_trip` becomes a computed
|
||||
* accessor on the model (`linked_booking_id !== null`), so the column
|
||||
* is dropped rather than kept redundant.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_round_trip', 'return_travel_date']);
|
||||
$table->foreignId('linked_booking_id')->nullable()->after('ev_route_id')
|
||||
->constrained('bookings')->nullOnDelete();
|
||||
$table->boolean('is_return_leg')->default(false)->after('linked_booking_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('linked_booking_id');
|
||||
$table->dropColumn('is_return_leg');
|
||||
$table->boolean('is_round_trip')->default(false);
|
||||
$table->date('return_travel_date')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
@@ -21,6 +22,12 @@ class AssignDriverAction
|
||||
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
if ($booking->travel_date->lt(today())) {
|
||||
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
|
||||
}
|
||||
|
||||
$isFirstAssignment = $booking->driver_name === null;
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => $data->driverName,
|
||||
'driver_phone' => $data->driverPhone,
|
||||
@@ -28,6 +35,13 @@ class AssignDriverAction
|
||||
'car_model' => $data->carModel,
|
||||
]);
|
||||
|
||||
// Guards against a double-submit of the same form resulting in two
|
||||
// identical SMS notifications to the passenger — a genuine
|
||||
// reassignment always changes at least one of these columns.
|
||||
if ($booking->wasChanged(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])) {
|
||||
DriverAssigned::dispatch($booking, $isFirstAssignment);
|
||||
}
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Exceptions\InvalidReturnRouteException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Services\BookingRefGenerator;
|
||||
use Modules\Booking\Services\BookingService;
|
||||
@@ -26,12 +27,75 @@ class CreateBookingAction
|
||||
{
|
||||
$this->bookingService->validateSelections($data->selections);
|
||||
|
||||
return DB::transaction(function () use ($data) {
|
||||
$route = EvRoute::findOrFail($data->evRouteId);
|
||||
$isRoundTrip = $data->returnEvRouteId !== null;
|
||||
|
||||
if ($isRoundTrip) {
|
||||
$this->bookingService->validateSelections($data->returnSelections);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $isRoundTrip) {
|
||||
$outboundRoute = EvRoute::findOrFail($data->evRouteId);
|
||||
|
||||
$outboundBooking = $this->createLeg(
|
||||
data: $data,
|
||||
route: $outboundRoute,
|
||||
selections: $data->selections,
|
||||
travelDate: $data->travelDate,
|
||||
timeSlotId: $data->departureTimeSlotId,
|
||||
isReturnLeg: false,
|
||||
);
|
||||
|
||||
if (! $isRoundTrip) {
|
||||
BookingCreated::dispatch($outboundBooking);
|
||||
|
||||
return $outboundBooking;
|
||||
}
|
||||
|
||||
$returnRoute = EvRoute::findOrFail($data->returnEvRouteId);
|
||||
|
||||
if (! $returnRoute->isReverseOf($outboundRoute)) {
|
||||
throw InvalidReturnRouteException::notReverseOfOutbound($returnRoute, $outboundRoute);
|
||||
}
|
||||
|
||||
$returnBooking = $this->createLeg(
|
||||
data: $data,
|
||||
route: $returnRoute,
|
||||
selections: $data->returnSelections,
|
||||
travelDate: $data->returnTravelDate,
|
||||
timeSlotId: $data->returnDepartureTimeSlotId,
|
||||
isReturnLeg: true,
|
||||
);
|
||||
|
||||
// Linked bidirectionally after both rows exist — a single
|
||||
// `linked_booking_id` FK can't be set on either row at create
|
||||
// time since the other side doesn't have an id yet.
|
||||
$returnBooking->update(['linked_booking_id' => $outboundBooking->id]);
|
||||
$outboundBooking->update(['linked_booking_id' => $returnBooking->id]);
|
||||
|
||||
// No registered listeners on BookingCreated today, so firing it
|
||||
// twice per round-trip creation has no side effects — flagged
|
||||
// here for whoever adds the first listener.
|
||||
BookingCreated::dispatch($outboundBooking);
|
||||
BookingCreated::dispatch($returnBooking);
|
||||
|
||||
return $outboundBooking->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<VehicleSelectionData> $selections
|
||||
*/
|
||||
private function createLeg(
|
||||
CreateBookingData $data,
|
||||
EvRoute $route,
|
||||
array $selections,
|
||||
string $travelDate,
|
||||
int $timeSlotId,
|
||||
bool $isReturnLeg,
|
||||
): Booking {
|
||||
$lines = array_map(
|
||||
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
||||
$data->selections,
|
||||
$selections,
|
||||
);
|
||||
|
||||
$totalPrice = array_reduce(
|
||||
@@ -44,11 +108,13 @@ class CreateBookingAction
|
||||
'booking_ref' => $this->bookingRefGenerator->generate(),
|
||||
'user_id' => $data->userId,
|
||||
'openid' => $data->openid,
|
||||
'ev_route_id' => $data->evRouteId,
|
||||
'departure_time_slot_id' => $data->departureTimeSlotId,
|
||||
'travel_date' => $data->travelDate,
|
||||
'ev_route_id' => $route->id,
|
||||
'is_return_leg' => $isReturnLeg,
|
||||
'departure_time_slot_id' => $timeSlotId,
|
||||
'travel_date' => $travelDate,
|
||||
'passenger_name' => $data->passengerName,
|
||||
'passenger_phone' => $data->passengerPhone,
|
||||
'notes' => $data->notes,
|
||||
'pickup_address' => $data->pickupAddress,
|
||||
'pickup_lat' => $data->pickupLat,
|
||||
'pickup_lng' => $data->pickupLng,
|
||||
@@ -57,17 +123,12 @@ class CreateBookingAction
|
||||
'dropoff_lng' => $data->dropoffLng,
|
||||
'price' => $totalPrice,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => $data->isRoundTrip,
|
||||
'return_travel_date' => $data->returnTravelDate,
|
||||
'created_by_channel' => $data->createdByChannel,
|
||||
]);
|
||||
|
||||
$booking->vehicleOptions()->createMany($lines);
|
||||
|
||||
BookingCreated::dispatch($booking);
|
||||
|
||||
return $booking;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Staff-only internal note, set from the admin panel
|
||||
* (SetRemarkTableAction). No status restriction — staff can annotate a
|
||||
* booking at any point in its lifecycle. Never exposed on the customer
|
||||
* BookingResource.
|
||||
*/
|
||||
class SetRemarkAction
|
||||
{
|
||||
public function handle(Booking $booking, ?string $remark): Booking
|
||||
{
|
||||
$booking->update(['remark' => $remark]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ readonly class CreateBookingData
|
||||
/**
|
||||
* @param list<VehicleSelectionData> $selections One or more Vehicle Option
|
||||
* selections (e.g. front_seat + back_seat) — domain.md §2.
|
||||
* @param list<VehicleSelectionData>|null $returnSelections Same shape as $selections,
|
||||
* priced independently against $returnEvRouteId. Presence of
|
||||
* $returnEvRouteId is the round-trip signal (domain.md §2b).
|
||||
*/
|
||||
public function __construct(
|
||||
public int $evRouteId,
|
||||
@@ -22,11 +25,14 @@ readonly class CreateBookingData
|
||||
public BookingChannel $createdByChannel,
|
||||
public ?int $userId = null,
|
||||
public ?string $openid = null,
|
||||
public ?string $notes = null,
|
||||
public ?float $pickupLat = null,
|
||||
public ?float $pickupLng = null,
|
||||
public ?float $dropoffLat = null,
|
||||
public ?float $dropoffLng = null,
|
||||
public bool $isRoundTrip = false,
|
||||
public ?int $returnEvRouteId = null,
|
||||
public ?int $returnDepartureTimeSlotId = null,
|
||||
public ?string $returnTravelDate = null,
|
||||
public ?array $returnSelections = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Fired whenever AssignDriverAction sets or updates a booking's
|
||||
* driver/vehicle details — covers both the first assignment and any later
|
||||
* reassignment, since both go through the same action. $isFirstAssignment
|
||||
* lets listeners (e.g. the SMS notification) word the message differently
|
||||
* for "driver assigned" vs "driver info updated".
|
||||
*/
|
||||
class DriverAssigned
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Booking $booking, public bool $isFirstAssignment) {}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
|
||||
);
|
||||
}
|
||||
|
||||
public static function travelDateInPast(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidReturnRouteException extends RuntimeException
|
||||
{
|
||||
public static function notReverseOfOutbound(EvRoute $returnRoute, EvRoute $outboundRoute): self
|
||||
{
|
||||
return new self(
|
||||
"Return route [{$returnRoute->id}] is not the reverse of outbound route [{$outboundRoute->id}] — ".
|
||||
'from/to destinations must be swapped.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected return route is a client input problem, not a server
|
||||
* error — surface it as 422, matching InvalidVehicleSelectionException.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ use RuntimeException;
|
||||
|
||||
class InvalidVehicleSelectionException extends RuntimeException
|
||||
{
|
||||
public static function frontSeatLimitExceeded(int $requested, int $max): self
|
||||
public static function passengerLimitExceeded(VehicleOption $vehicleOption, int $requested, int $max): self
|
||||
{
|
||||
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking.");
|
||||
return new self("Vehicle option [{$vehicleOption->value}] passenger count [{$requested}] exceeds the max of [{$max}] per booking.");
|
||||
}
|
||||
|
||||
public static function optionDisabled(VehicleOption $vehicleOption): self
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ class AssignDriverTableAction
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('primary')
|
||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||
&& $record->travel_date->gte(today())
|
||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||
->schema([
|
||||
TextInput::make('driver_name')->required(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Booking\Actions\SetRemarkAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Shared between BookingsTable (row action) and ViewBooking (header action)
|
||||
* so both surfaces stay in sync — one definition, not two.
|
||||
*/
|
||||
class SetRemarkTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('setRemark')
|
||||
->label('Remark')
|
||||
->icon(Heroicon::OutlinedPencilSquare)
|
||||
->color('gray')
|
||||
->visible(fn (): bool => auth()->user()?->can('manage_bookings') ?? false)
|
||||
->schema([
|
||||
Textarea::make('remark')->maxLength(1000),
|
||||
])
|
||||
->fillForm(fn (Booking $record): array => [
|
||||
'remark' => $record->remark,
|
||||
])
|
||||
->action(function (array $data, Booking $record, SetRemarkAction $setRemarkAction) {
|
||||
$setRemarkAction->handle($record, $data['remark'] ?: null);
|
||||
|
||||
Notification::make()
|
||||
->title('Remark saved')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
use Modules\Payment\Filament\Actions\RefundBookingTableAction;
|
||||
|
||||
class ViewBooking extends ViewRecord
|
||||
{
|
||||
@@ -15,7 +17,9 @@ class ViewBooking extends ViewRecord
|
||||
{
|
||||
return [
|
||||
AssignDriverTableAction::make(),
|
||||
SetRemarkTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
RefundBookingTableAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
class BookingInfolist
|
||||
@@ -32,7 +33,8 @@ class BookingInfolist
|
||||
TextEntry::make('created_by_channel')->badge(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Trip')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
@@ -43,10 +45,17 @@ class BookingInfolist
|
||||
TextEntry::make('timeSlot.label')->label('Time Slot'),
|
||||
TextEntry::make('travel_date')->date(),
|
||||
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
|
||||
TextEntry::make('return_travel_date')->date()
|
||||
TextEntry::make('is_return_leg')->label('Leg')->badge()
|
||||
->formatStateUsing(fn (bool $state) => $state ? 'Return' : 'Outbound')
|
||||
->visible(fn ($record) => $record->is_round_trip),
|
||||
TextEntry::make('linkedBooking.booking_ref')->label('Linked Leg')
|
||||
->visible(fn ($record) => $record->is_round_trip)
|
||||
->url(fn ($record) => $record->linked_booking_id
|
||||
? BookingResource::getUrl('view', ['record' => $record->linked_booking_id])
|
||||
: null),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Vehicle Options')
|
||||
->schema([
|
||||
RepeatableEntry::make('vehicleOptions')
|
||||
@@ -61,15 +70,29 @@ class BookingInfolist
|
||||
]),
|
||||
]),
|
||||
TextEntry::make('price')->label('Total Price')->numeric(2),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Passenger')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('passenger_name'),
|
||||
TextEntry::make('passenger_phone'),
|
||||
TextEntry::make('notes')
|
||||
->label('Customer Notes')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Staff Remark')
|
||||
->description('Internal only — never shown to the customer. Set via the Remark action.')
|
||||
->schema([
|
||||
TextEntry::make('remark')
|
||||
->label('')
|
||||
->placeholder('No remark yet.'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Pickup & Dropoff')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
@@ -81,7 +104,8 @@ class BookingInfolist
|
||||
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Driver & Vehicle')
|
||||
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
|
||||
->schema([
|
||||
@@ -92,7 +116,8 @@ class BookingInfolist
|
||||
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
// A booking can have more than one payment attempt if an
|
||||
// earlier one failed and the customer retried (domain.md §1)
|
||||
// — full detail (gateway response, refunds) lives on the
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Modules\Booking\Filament\Resources\Bookings\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
@@ -15,8 +17,10 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Payment\Filament\Actions\RefundBookingTableAction;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
class BookingsTable
|
||||
@@ -54,6 +58,10 @@ class BookingsTable
|
||||
->sortable(),
|
||||
TextColumn::make('timeSlot.label')
|
||||
->label('Time Slot'),
|
||||
IconColumn::make('is_round_trip')
|
||||
->label('Round Trip')
|
||||
->boolean()
|
||||
->toggleable(),
|
||||
TextColumn::make('vehicleOptions')
|
||||
->label('Vehicle Options')
|
||||
->state(fn (Booking $record) => $record->vehicleOptions
|
||||
@@ -79,6 +87,16 @@ class BookingsTable
|
||||
->join(' • ') ?: null)
|
||||
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->toggleable(),
|
||||
TextColumn::make('notes')
|
||||
->label('Customer Notes')
|
||||
->placeholder('—')
|
||||
->limit(50)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('remark')
|
||||
->label('Staff Remark')
|
||||
->placeholder('—')
|
||||
->limit(50)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@@ -112,6 +130,15 @@ class BookingsTable
|
||||
$data['value'] ?? null,
|
||||
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
|
||||
)),
|
||||
// is_round_trip is a computed accessor (linked_booking_id
|
||||
// !== null), not a DB column — TernaryFilter builds a raw
|
||||
// where() on it, which breaks now that the column is gone.
|
||||
Filter::make('is_round_trip')
|
||||
->schema([Toggle::make('is_round_trip')])
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['is_round_trip'] ?? null,
|
||||
fn (Builder $q) => $q->whereNotNull('linked_booking_id'),
|
||||
)),
|
||||
// Deleted bookings are soft-deleted, not hard-removed
|
||||
// (domain.md; T7.x follow-up) — this is the only place they
|
||||
// become visible again, off by default.
|
||||
@@ -120,7 +147,9 @@ class BookingsTable
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
SetRemarkTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
RefundBookingTableAction::make(),
|
||||
DeleteBookingTableAction::make(),
|
||||
RestoreBookingTableAction::make(),
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Http\Requests\StoreBookingRequest;
|
||||
use Modules\Booking\Http\Resources\BookingResource;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingController extends Controller
|
||||
@@ -22,7 +23,11 @@ class BookingController extends Controller
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions'];
|
||||
private const EAGER_LOADS = [
|
||||
'route', 'timeSlot', 'vehicleOptions',
|
||||
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
|
||||
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private CreateBookingAction $createBookingAction,
|
||||
@@ -46,6 +51,17 @@ class BookingController extends Controller
|
||||
}
|
||||
|
||||
$bookings = $query
|
||||
// Only bookings that actually have a completed payment — a
|
||||
// pending_payment booking never had money move, so it's noise
|
||||
// in a booking list, not a real reservation to show.
|
||||
->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed))
|
||||
// A round trip is two Booking rows (outbound + return leg,
|
||||
// linked via linked_booking_id — domain.md §2b), but it should
|
||||
// still surface once here, not as two separate list entries.
|
||||
// The outbound row's `linked_booking` already carries the
|
||||
// return leg's full detail (including vehicle_options).
|
||||
->where('is_return_leg', false)
|
||||
->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%'))
|
||||
->with(self::EAGER_LOADS)
|
||||
->latest()
|
||||
->paginate();
|
||||
@@ -83,6 +99,18 @@ class BookingController extends Controller
|
||||
$validated['selections'],
|
||||
);
|
||||
|
||||
$isRoundTrip = $validated['is_round_trip'] ?? false;
|
||||
|
||||
$returnSelections = $isRoundTrip
|
||||
? array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
vehicleOption: VehicleOption::from($selection['vehicle_option']),
|
||||
passengerCount: $selection['passenger_count'],
|
||||
),
|
||||
$validated['return_selections'],
|
||||
)
|
||||
: null;
|
||||
|
||||
// The agent's own auth path always wins over anything a header could
|
||||
// claim; customer channels come from Device-Type, not a
|
||||
// client-supplied body field (BookingChannel::fromDeviceTypeHeader
|
||||
@@ -98,6 +126,7 @@ class BookingController extends Controller
|
||||
selections: $selections,
|
||||
passengerName: $validated['passenger_name'],
|
||||
passengerPhone: $validated['passenger_phone'],
|
||||
notes: $validated['notes'] ?? null,
|
||||
pickupAddress: $validated['pickup_address'],
|
||||
dropoffAddress: $validated['dropoff_address'],
|
||||
createdByChannel: $channel,
|
||||
@@ -110,8 +139,10 @@ class BookingController extends Controller
|
||||
pickupLng: $validated['pickup_lng'] ?? null,
|
||||
dropoffLat: $validated['dropoff_lat'] ?? null,
|
||||
dropoffLng: $validated['dropoff_lng'] ?? null,
|
||||
isRoundTrip: $validated['is_round_trip'] ?? false,
|
||||
returnEvRouteId: $isRoundTrip ? $validated['return_ev_route_id'] : null,
|
||||
returnDepartureTimeSlotId: $isRoundTrip ? $validated['return_departure_time_slot_id'] : null,
|
||||
returnTravelDate: $validated['return_travel_date'] ?? null,
|
||||
returnSelections: $returnSelections,
|
||||
));
|
||||
|
||||
return (new BookingResource($booking->load(self::EAGER_LOADS)))
|
||||
|
||||
@@ -34,14 +34,24 @@ class StoreBookingRequest extends FormRequest
|
||||
'selections.*.passenger_count' => ['required', 'integer', 'min:1'],
|
||||
'passenger_name' => ['required', 'string', 'max:255'],
|
||||
'passenger_phone' => ['required', 'string', 'max:50'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'pickup_address' => ['required', 'string', 'max:500'],
|
||||
'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'dropoff_address' => ['required', 'string', 'max:500'],
|
||||
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
// Round trip = a second, independently-priced leg on its own
|
||||
// route/time-slot/date — the return route must already exist as
|
||||
// a catalog EvRoute and is validated server-side as the true
|
||||
// reverse of ev_route_id (EvRoute::isReverseOf, domain.md §2b).
|
||||
'is_round_trip' => ['sometimes', 'boolean'],
|
||||
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'],
|
||||
'return_ev_route_id' => ['required_if:is_round_trip,true', 'integer', 'exists:ev_routes,id'],
|
||||
'return_departure_time_slot_id' => ['required_if:is_round_trip,true', 'integer', 'exists:departure_time_slots,id'],
|
||||
'return_travel_date' => ['required_if:is_round_trip,true', 'date', 'after_or_equal:travel_date'],
|
||||
'return_selections' => ['required_if:is_round_trip,true', 'array', 'min:1'],
|
||||
'return_selections.*.vehicle_option' => ['required_if:is_round_trip,true', Rule::enum(VehicleOption::class)],
|
||||
'return_selections.*.passenger_count' => ['required_if:is_round_trip,true', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ class BookingResource extends JsonResource
|
||||
'status' => $this->status,
|
||||
'travel_date' => $this->travel_date?->toDateString(),
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'return_travel_date' => $this->return_travel_date?->toDateString(),
|
||||
'is_return_leg' => $this->is_return_leg,
|
||||
'passenger_name' => $this->passenger_name,
|
||||
'passenger_phone' => $this->passenger_phone,
|
||||
'notes' => $this->notes,
|
||||
'pickup_address' => $this->pickup_address,
|
||||
'pickup_lat' => $this->pickup_lat,
|
||||
'pickup_lng' => $this->pickup_lng,
|
||||
@@ -30,6 +31,15 @@ class BookingResource extends JsonResource
|
||||
'dropoff_lat' => $this->dropoff_lat,
|
||||
'dropoff_lng' => $this->dropoff_lng,
|
||||
'price' => $this->price,
|
||||
// This leg's own price, same value CancelBookingAction/
|
||||
// RefundBookingAction use for this specific leg. total_price is
|
||||
// the round-trip total (this leg + linked leg) — computed here,
|
||||
// not left to the client to sum, since it must always match what
|
||||
// InitiatePaymentAction actually charges (bcadd, same as there).
|
||||
// Equal to `price` for a plain one-way booking.
|
||||
'total_price' => $this->relationLoaded('linkedBooking') && $this->linkedBooking !== null
|
||||
? bcadd((string) $this->price, (string) $this->linkedBooking->price, 2)
|
||||
: $this->price,
|
||||
'created_by_channel' => $this->created_by_channel,
|
||||
// Only ever populated once status is confirmed — see AssignDriverAction.
|
||||
'driver_name' => $this->driver_name,
|
||||
@@ -53,6 +63,44 @@ class BookingResource extends JsonResource
|
||||
'label' => $this->timeSlot->label,
|
||||
'time' => $this->timeSlot->time?->format('H:i'),
|
||||
]),
|
||||
// Hand-built, not a nested BookingResource — the linked leg's
|
||||
// own linked_booking points right back here, so nesting the
|
||||
// full resource would recurse forever (domain.md §2b).
|
||||
'linked_booking' => $this->whenLoaded('linkedBooking', fn () => [
|
||||
'id' => $this->linkedBooking->id,
|
||||
'booking_ref' => $this->linkedBooking->booking_ref,
|
||||
'status' => $this->linkedBooking->status,
|
||||
'travel_date' => $this->linkedBooking->travel_date?->toDateString(),
|
||||
'is_return_leg' => $this->linkedBooking->is_return_leg,
|
||||
'route' => $this->linkedBooking->relationLoaded('route') ? [
|
||||
'id' => $this->linkedBooking->route->id,
|
||||
'ev_company_id' => $this->linkedBooking->route->ev_company_id,
|
||||
'from_destination_id' => $this->linkedBooking->route->from_destination_id,
|
||||
'to_destination_id' => $this->linkedBooking->route->to_destination_id,
|
||||
] : null,
|
||||
'time_slot' => $this->linkedBooking->relationLoaded('timeSlot') ? [
|
||||
'id' => $this->linkedBooking->timeSlot->id,
|
||||
'label' => $this->linkedBooking->timeSlot->label,
|
||||
'time' => $this->linkedBooking->timeSlot->time?->format('H:i'),
|
||||
] : null,
|
||||
'vehicle_options' => $this->linkedBooking->relationLoaded('vehicleOptions')
|
||||
? $this->linkedBooking->vehicleOptions->map(fn ($selection) => [
|
||||
'vehicle_option' => $selection->vehicle_option,
|
||||
'passenger_count' => $selection->passenger_count,
|
||||
'unit_price' => $selection->unit_price,
|
||||
'line_total' => $selection->line_total,
|
||||
])
|
||||
: null,
|
||||
// Each leg gets its own independent driver/vehicle
|
||||
// assignment — the return leg is never guaranteed the same
|
||||
// car as the outbound leg (domain.md §2b). Only ever
|
||||
// populated once that leg's own status is confirmed — see
|
||||
// AssignDriverAction.
|
||||
'driver_name' => $this->linkedBooking->driver_name,
|
||||
'driver_phone' => $this->linkedBooking->driver_phone,
|
||||
'car_plate_number' => $this->linkedBooking->car_plate_number,
|
||||
'car_model' => $this->linkedBooking->car_model,
|
||||
]),
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Listeners;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
/**
|
||||
* Notifies the passenger of their driver/car details whenever a driver is
|
||||
* assigned or reassigned (domain.md — driver/vehicle assignment). Queued
|
||||
* since it's an outbound HTTP call to the SMS gateway.
|
||||
*/
|
||||
class SendDriverAssignedSms implements ShouldQueue
|
||||
{
|
||||
public function __construct(private readonly SmsService $smsService) {}
|
||||
|
||||
public function handle(DriverAssigned $event): void
|
||||
{
|
||||
$booking = $event->booking;
|
||||
|
||||
$this->smsService->send($booking->passenger_phone, $this->message($event));
|
||||
}
|
||||
|
||||
private function message(DriverAssigned $event): string
|
||||
{
|
||||
$booking = $event->booking;
|
||||
|
||||
$vehicle = trim($booking->car_model !== null
|
||||
? "{$booking->car_plate_number} ({$booking->car_model})"
|
||||
: $booking->car_plate_number);
|
||||
$route = $booking->route->fromDestination->name.' - '.$booking->route->toDestination->name;
|
||||
$mmRoute = $booking->route->fromDestination->mm_name.' - '.$booking->route->toDestination->mm_name;
|
||||
|
||||
$appName = 'BNF Express - '.config('app.name');
|
||||
$supportPhone = config('app.support_phone');
|
||||
$supportEmail = config('app.support_email');
|
||||
$contact = "Help: {$supportPhone} / {$supportEmail}\nအကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်: {$supportPhone} / {$supportEmail}";
|
||||
|
||||
if ($event->isFirstAssignment) {
|
||||
$en = "Your driver has been assigned for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
|
||||
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) အတွက် ယာဉ်မောင်း သတ်မှတ်ပြီးပါပြီ။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}။";
|
||||
} else {
|
||||
$en = "Driver info updated for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
|
||||
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) ၏ ယာဉ်မောင်းအချက်အလက်ကို ပြင်ဆင်ထားပါသည်။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}။";
|
||||
}
|
||||
|
||||
return "{$appName}\n{$en}\n{$mm}\n{$contact}";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -43,10 +44,14 @@ class Booking extends Model
|
||||
'user_id',
|
||||
'openid',
|
||||
'ev_route_id',
|
||||
'linked_booking_id',
|
||||
'is_return_leg',
|
||||
'departure_time_slot_id',
|
||||
'travel_date',
|
||||
'passenger_name',
|
||||
'passenger_phone',
|
||||
'notes',
|
||||
'remark',
|
||||
'pickup_address',
|
||||
'pickup_lat',
|
||||
'pickup_lng',
|
||||
@@ -55,8 +60,6 @@ class Booking extends Model
|
||||
'dropoff_lng',
|
||||
'price',
|
||||
'status',
|
||||
'is_round_trip',
|
||||
'return_travel_date',
|
||||
'created_by_channel',
|
||||
'driver_name',
|
||||
'driver_phone',
|
||||
@@ -77,8 +80,7 @@ class Booking extends Model
|
||||
'dropoff_lng' => 'decimal:7',
|
||||
'price' => 'decimal:2',
|
||||
'status' => BookingStatus::class,
|
||||
'is_round_trip' => 'boolean',
|
||||
'return_travel_date' => 'date',
|
||||
'is_return_leg' => 'boolean',
|
||||
'created_by_channel' => BookingChannel::class,
|
||||
];
|
||||
}
|
||||
@@ -93,6 +95,16 @@ class Booking extends Model
|
||||
return $this->belongsTo(EvRoute::class, 'ev_route_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The other leg of a round trip (outbound <-> return), linked
|
||||
* bidirectionally by CreateBookingAction. Null for a plain one-way
|
||||
* booking — see the `isRoundTrip()` accessor (domain.md §2b).
|
||||
*/
|
||||
public function linkedBooking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class, 'linked_booking_id');
|
||||
}
|
||||
|
||||
public function timeSlot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id');
|
||||
@@ -107,4 +119,17 @@ class Booking extends Model
|
||||
{
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this booking has a linked leg — i.e. it's one half of a
|
||||
* round trip. Computed, not stored: presence of `linked_booking_id` is
|
||||
* the single source of truth, so it can't drift out of sync the way a
|
||||
* separate flag column could (domain.md §2b).
|
||||
*/
|
||||
public function isRoundTrip(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (): bool => $this->linked_booking_id !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace Modules\Booking\Providers;
|
||||
|
||||
use Illuminate\Contracts\Auth\Access\Gate;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
|
||||
class BookingServiceProvider extends ServiceProvider
|
||||
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
|
||||
public function boot(Gate $gate): void
|
||||
{
|
||||
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
|
||||
|
||||
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ use Modules\Shared\Enums\VehicleOption;
|
||||
class BookingService
|
||||
{
|
||||
/**
|
||||
* Enforces the only v1 inventory rule (max Front Seats per booking), the
|
||||
* blunt config toggles for Back Seat / Whole Vehicle availability, and
|
||||
* shape rules around combining options in one booking (no duplicate
|
||||
* option lines, Whole Vehicle can't be mixed with anything else since it
|
||||
* already covers the whole car).
|
||||
* Enforces the same two blunt, config-driven rules for every Vehicle
|
||||
* Option — an on/off toggle and a max passenger_count per booking (see
|
||||
* domain.md §2) — plus shape rules around combining options in one
|
||||
* booking (no duplicate option lines, Whole Vehicle can't be mixed with
|
||||
* anything else since it already covers the whole car).
|
||||
*
|
||||
* Deliberately does not check real capacity/availability — that's an
|
||||
* explicitly deferred future phase (domain.md §2, §7).
|
||||
@@ -43,26 +43,23 @@ class BookingService
|
||||
|
||||
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
|
||||
{
|
||||
match ($vehicleOption) {
|
||||
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount),
|
||||
VehicleOption::BackSeat => $this->validateEnabled($vehicleOption, 'booking.back_seat_enabled'),
|
||||
VehicleOption::WholeVehicle => $this->validateEnabled($vehicleOption, 'booking.whole_vehicle_enabled'),
|
||||
};
|
||||
$this->validateEnabled($vehicleOption);
|
||||
$this->validateMax($vehicleOption, $passengerCount);
|
||||
}
|
||||
|
||||
private function validateFrontSeat(int $passengerCount): void
|
||||
private function validateEnabled(VehicleOption $vehicleOption): void
|
||||
{
|
||||
$max = config('booking.front_seat_max_per_booking');
|
||||
|
||||
if ($passengerCount > $max) {
|
||||
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
|
||||
{
|
||||
if (! config($configKey)) {
|
||||
if (! config("booking.{$vehicleOption->value}_enabled")) {
|
||||
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateMax(VehicleOption $vehicleOption, int $passengerCount): void
|
||||
{
|
||||
$max = config("booking.{$vehicleOption->value}_max_per_booking");
|
||||
|
||||
if ($passengerCount > $max) {
|
||||
throw InvalidVehicleSelectionException::passengerLimitExceeded($vehicleOption, $passengerCount, $max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,37 @@ test('front-seat-limit rejection surfaces as 422', function () {
|
||||
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.');
|
||||
->assertJsonPath('message', 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('back-seat-limit rejection surfaces as 422', function () {
|
||||
config(['booking.back_seat_max_per_booking' => 1]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Vehicle option [back_seat] passenger count [2] exceeds the max of [1] per booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('whole-vehicle-limit rejection surfaces as 422', function () {
|
||||
config(['booking.whole_vehicle_max_per_booking' => 1]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Vehicle option [whole_vehicle] passenger count [2] exceeds the max of [1] per booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
@@ -206,6 +236,33 @@ test('created_by_channel is taken from the Device-Type header', function (string
|
||||
'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp],
|
||||
]);
|
||||
|
||||
test('customer-supplied notes are stored and returned', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['notes'] = 'Please call before arriving.';
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.notes', 'Please call before arriving.');
|
||||
|
||||
expect(Booking::first()->notes)->toBe('Please call before arriving.');
|
||||
});
|
||||
|
||||
test('notes is optional and defaults to null', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.notes', null);
|
||||
});
|
||||
|
||||
test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
@@ -221,3 +278,122 @@ test('a Device-Type header cannot spoof the agent or admin channel', function (s
|
||||
'admin' => ['admin'],
|
||||
'unrecognized value' => ['smart-fridge'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Same company as $outbound, from/to swapped — the true reverse route.
|
||||
*
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function reverseRouteAndSlot(EvRoute $outbound, array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outbound->ev_company_id,
|
||||
'from_destination_id' => $outbound->to_destination_id,
|
||||
'to_destination_id' => $outbound->from_destination_id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
|
||||
|
||||
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => $vehicleOption,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
test('round trip: creates two linked bookings, each priced against its own route', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $returnRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $returnSlot->id;
|
||||
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.is_round_trip', true)
|
||||
->assertJsonPath('data.is_return_leg', false)
|
||||
->assertJsonPath('data.price', '9000.00')
|
||||
->assertJsonPath('data.linked_booking.is_return_leg', true)
|
||||
->assertJsonPath('data.linked_booking.route.id', $returnRoute->id)
|
||||
->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat')
|
||||
->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00');
|
||||
|
||||
expect(Booking::count())->toBe(2);
|
||||
|
||||
$return = Booking::where('is_return_leg', true)->firstOrFail();
|
||||
expect($return->price)->toEqual('11000.00')
|
||||
->and($return->ev_route_id)->toBe($returnRoute->id);
|
||||
});
|
||||
|
||||
test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $unrelatedRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $unrelatedSlot->id;
|
||||
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('round trip: return fields are required when is_round_trip is true', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections',
|
||||
]);
|
||||
});
|
||||
|
||||
test('round trip: return_travel_date before travel_date is rejected', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $returnRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $returnSlot->id;
|
||||
$payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay())
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['return_travel_date']);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -11,10 +14,27 @@ beforeEach(function () {
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
/**
|
||||
* Index only ever shows bookings with a completed payment — give the
|
||||
* booking a completed Payment row so it's not silently excluded.
|
||||
*/
|
||||
function paidBooking(array $attributes = []): Booking
|
||||
{
|
||||
$booking = Booking::factory()->create($attributes);
|
||||
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => $booking->price,
|
||||
]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
test('index lists only the authenticated user\'s own bookings, latest first', function () {
|
||||
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
||||
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
$mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
||||
$mineNewer = paidBooking(['user_id' => $this->owner->id]);
|
||||
paidBooking(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
@@ -24,6 +44,115 @@ test('index lists only the authenticated user\'s own bookings, latest first', fu
|
||||
->assertJsonPath('data.1.id', $mine->id);
|
||||
});
|
||||
|
||||
test('index excludes bookings with no completed payment', function () {
|
||||
// pending_payment, never paid.
|
||||
Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
// Has a payment attempt, but it failed — still not "complete".
|
||||
$failedPayment = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
Payment::factory()->failed()->create(['booking_id' => $failedPayment->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
|
||||
$paid = paidBooking(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $paid->id);
|
||||
});
|
||||
|
||||
test('index surfaces a round trip once, not as two separate rows, with a combined total_price', function () {
|
||||
$outbound = paidBooking(['user_id' => $this->owner->id, 'price' => '9000.00']);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'price' => '11000.00',
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $outbound->id)
|
||||
->assertJsonPath('data.0.price', '9000.00')
|
||||
->assertJsonPath('data.0.total_price', '20000.00')
|
||||
->assertJsonPath('data.0.linked_booking.id', $return->id);
|
||||
});
|
||||
|
||||
test('linked_booking carries the return leg\'s own driver/vehicle assignment, independent of the outbound leg\'s', function () {
|
||||
$outbound = paidBooking([
|
||||
'user_id' => $this->owner->id,
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'Daw Hla',
|
||||
'driver_phone' => '+959444555666',
|
||||
'car_plate_number' => 'MDY-5678',
|
||||
'car_model' => null,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.driver_name', 'U Aung')
|
||||
->assertJsonPath('data.car_plate_number', 'YGN-1234')
|
||||
->assertJsonPath('data.linked_booking.driver_name', 'Daw Hla')
|
||||
->assertJsonPath('data.linked_booking.driver_phone', '+959444555666')
|
||||
->assertJsonPath('data.linked_booking.car_plate_number', 'MDY-5678')
|
||||
->assertJsonPath('data.linked_booking.car_model', null);
|
||||
});
|
||||
|
||||
test('total_price equals price for a plain one-way booking, on both index and show', function () {
|
||||
$booking = paidBooking(['user_id' => $this->owner->id, 'price' => '15000.00']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertJsonPath('data.0.total_price', '15000.00');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertJsonPath('data.total_price', '15000.00');
|
||||
});
|
||||
|
||||
test('show returns the combined total_price for a round trip', function () {
|
||||
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'price' => '9000.00']);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'price' => '11000.00',
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.price', '9000.00')
|
||||
->assertJsonPath('data.total_price', '20000.00');
|
||||
});
|
||||
|
||||
test('index filters by booking_ref, partial and case-insensitive', function () {
|
||||
$match = paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-FINDME1']);
|
||||
paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-OTHER01']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings?booking_ref=findme')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $match->id);
|
||||
});
|
||||
|
||||
test('index rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/bookings')->assertUnauthorized();
|
||||
});
|
||||
|
||||
@@ -7,12 +7,46 @@ use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests (mirrors RefundResourceTest's
|
||||
* fake for the Refunds resource's own process action).
|
||||
*/
|
||||
class FakeBookingResourceRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
@@ -149,6 +183,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
|
||||
->assertTableActionHidden('assignDriver', $pending);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden once the travel date has passed', function () {
|
||||
$past = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()->subDay()]);
|
||||
$today = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('assignDriver', $past)
|
||||
->assertTableActionVisible('assignDriver', $today);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
@@ -325,6 +368,45 @@ test('restoring a deleted booking brings it back', function () {
|
||||
expect(Booking::find($booking->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the remark action is visible for a user with manage_bookings', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('setRemark', $booking);
|
||||
});
|
||||
|
||||
test('the remark action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('setRemark', $booking);
|
||||
});
|
||||
|
||||
test('calling the remark action sets the staff remark on a booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('setRemark', $booking, data: [
|
||||
'remark' => 'Passenger requested a child seat.',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->remark)->toBe('Passenger requested a child seat.');
|
||||
});
|
||||
|
||||
test('the remark form is pre-filled with the booking\'s existing remark', function () {
|
||||
$booking = Booking::factory()->create(['remark' => 'Existing remark.']);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->mountTableAction('setRemark', $booking)
|
||||
->assertTableActionDataSet([
|
||||
'remark' => 'Existing remark.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the restore action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
@@ -336,3 +418,100 @@ test('the restore action is hidden from a user without manage_bookings', functio
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $booking);
|
||||
});
|
||||
|
||||
test('the refund action is visible and enabled for a confirmed booking with process_refunds', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('refund', $booking)
|
||||
->assertTableActionEnabled('refund', $booking);
|
||||
});
|
||||
|
||||
test('the refund action is visible but disabled for a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('refund', $booking)
|
||||
->assertTableActionDisabled('refund', $booking);
|
||||
});
|
||||
|
||||
test('the refund action is hidden from a user without process_refunds', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('refund', $booking);
|
||||
});
|
||||
|
||||
test('calling the refund action from the bookings list with full refund toggled on refunds the whole balance', function () {
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeBookingResourceRefundGateway::class);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
$payment = Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-BOOKING-REFUND-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('refund', $booking, data: [
|
||||
'full_refund' => true,
|
||||
'reason' => 'customer requested cancellation',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 15000)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('calling the refund action with full refund toggled off refunds only the given amount', function () {
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeBookingResourceRefundGateway::class);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
$payment = Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-BOOKING-PARTIAL-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('refund', $booking, data: [
|
||||
'full_refund' => false,
|
||||
'amount' => 5000,
|
||||
'reason' => 'customer requested cancellation',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 5000)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the refund action\'s amount field is capped at the booking\'s payment\'s refundable balance', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-BOOKING-MAX-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('refund', $booking, data: [
|
||||
'full_refund' => false,
|
||||
'amount' => 15000.01,
|
||||
'reason' => 'reason',
|
||||
])
|
||||
->assertHasTableActionErrors(['amount' => 'max']);
|
||||
|
||||
expect(Refund::where('booking_id', $booking->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the detail page also has a refund action, shared with the table', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()])
|
||||
->assertActionVisible('refund')
|
||||
->assertActionEnabled('refund');
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@ use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Exceptions\InvalidReturnRouteException;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Exceptions\RoutePricingNotFoundException;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
@@ -33,7 +35,7 @@ function makeBookableRoute(array $pricedOptions): array
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData
|
||||
{
|
||||
return new CreateBookingData(
|
||||
evRouteId: $route->id,
|
||||
@@ -46,9 +48,38 @@ function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selecti
|
||||
dropoffAddress: '456 Dropoff Ave',
|
||||
createdByChannel: BookingChannel::MiniApp,
|
||||
openid: 'mini-app-openid-123',
|
||||
returnEvRouteId: $roundTrip['route']->id ?? null,
|
||||
returnDepartureTimeSlotId: $roundTrip['timeSlot']->id ?? null,
|
||||
returnTravelDate: $roundTrip['travelDate'] ?? (isset($roundTrip['route']) ? now()->addDays(3)->toDateString() : null),
|
||||
returnSelections: $roundTrip['selections'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same company as $outbound, from/to swapped — the true reverse route.
|
||||
*
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function makeReverseRoute(EvRoute $outbound, array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outbound->ev_company_id,
|
||||
'from_destination_id' => $outbound->to_destination_id,
|
||||
'to_destination_id' => $outbound->from_destination_id,
|
||||
]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => $vehicleOption,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
@@ -150,3 +181,144 @@ test('each booking created gets a unique, sequential booking_ref', function () {
|
||||
expect($first->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($second->booking_ref)->toBe('EVB-AAAAA2');
|
||||
});
|
||||
|
||||
test('a plain one-way booking has no linked leg', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
||||
);
|
||||
|
||||
expect($booking->linked_booking_id)->toBeNull()
|
||||
->and($booking->is_round_trip)->toBeFalse()
|
||||
->and($booking->is_return_leg)->toBeFalse()
|
||||
->and(Booking::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a round trip creates two bookings linked bidirectionally, each priced independently', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
|
||||
|
||||
$outbound = app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
));
|
||||
|
||||
expect(Booking::count())->toBe(2)
|
||||
->and($outbound->is_return_leg)->toBeFalse()
|
||||
->and($outbound->is_round_trip)->toBeTrue()
|
||||
->and($outbound->price)->toEqual('9000.00');
|
||||
|
||||
$return = $outbound->linkedBooking;
|
||||
|
||||
expect($return)->not->toBeNull()
|
||||
->and($return->is_return_leg)->toBeTrue()
|
||||
->and($return->is_round_trip)->toBeTrue()
|
||||
->and($return->linked_booking_id)->toBe($outbound->id)
|
||||
->and($return->ev_route_id)->toBe($returnRoute->id)
|
||||
->and($return->departure_time_slot_id)->toBe($returnSlot->id)
|
||||
->and($return->price)->toEqual('11000.00');
|
||||
});
|
||||
|
||||
test('a round trip dispatches BookingCreated for both legs', function () {
|
||||
Event::fake([BookingCreated::class]);
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$outbound = app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
));
|
||||
|
||||
Event::assertDispatched(BookingCreated::class, 2);
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound));
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking));
|
||||
});
|
||||
|
||||
test('it rejects a return route that is not the reverse of the outbound route', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
// Unrelated route — not from/to swapped.
|
||||
[$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $unrelatedRoute,
|
||||
'timeSlot' => $unrelatedSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
)))->toThrow(InvalidReturnRouteException::class);
|
||||
|
||||
// The whole transaction rolls back — no orphan outbound-only booking.
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('return leg selections are validated independently of the outbound leg', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::FrontSeat, 1)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
// Front seat max per booking is 1 — this should fail validation
|
||||
// for the return leg even though the outbound leg is valid.
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)],
|
||||
],
|
||||
)))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a failed return-leg price lookup rolls back the outbound leg too', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
// Return route exists (true reverse) but has no pricing rows at all.
|
||||
$returnRoute = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outboundRoute->ev_company_id,
|
||||
'from_destination_id' => $outboundRoute->to_destination_id,
|
||||
'to_destination_id' => $outboundRoute->from_destination_id,
|
||||
]);
|
||||
$returnSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
)))->toThrow(RoutePricingNotFoundException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ use Firebase\JWT\JWT;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
@@ -59,6 +61,7 @@ test('a FastAPI JWT booking is stored against the verified openid, ignoring a sp
|
||||
|
||||
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
|
||||
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
|
||||
Payment::factory()->completed()->create(['booking_id' => $mine->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||
|
||||
$token = fastApiAgentToken('agent-openid-mine');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.name' => 'FamousLY4 EV',
|
||||
'app.support_phone' => '+959123456789',
|
||||
'app.support_email' => 'support@famousLY4.test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
|
||||
&& str_contains($message, 'U Aung')
|
||||
&& str_contains($message, 'YGN-1234')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
|
||||
});
|
||||
|
||||
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'Daw Hla',
|
||||
'driver_phone' => '+959444555666',
|
||||
'car_plate_number' => 'YGN-5678',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
|
||||
&& str_contains($message, 'Daw Hla')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
|
||||
->and($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('it dispatches DriverAssigned with isFirstAssignment true for a booking with no prior driver', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->booking->is($booking) && $event->isFirstAssignment === true);
|
||||
});
|
||||
|
||||
test('it dispatches DriverAssigned with isFirstAssignment false when reassigning', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'Daw Hla',
|
||||
driverPhone: '+959444555666',
|
||||
carPlateNumber: 'YGN-5678',
|
||||
));
|
||||
|
||||
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->isFirstAssignment === false);
|
||||
});
|
||||
|
||||
test('it does not dispatch DriverAssigned again when resubmitted with identical driver/car details', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
carModel: 'Tesla Model Y',
|
||||
));
|
||||
|
||||
Event::assertNotDispatched(DriverAssigned::class);
|
||||
});
|
||||
|
||||
test('car_model is optional', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver when the travel date has already passed', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'travel_date' => today()->subDay(),
|
||||
]);
|
||||
|
||||
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
)))->toThrow(DriverAssignmentNotAllowedException::class);
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('it allows assigning a driver when the travel date is today', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'travel_date' => today(),
|
||||
]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
expect($updated->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver to a cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
@@ -74,3 +157,25 @@ test('reassigning a different driver on a still-confirmed booking overwrites the
|
||||
expect($booking->refresh()->driver_name)->toBe('Daw Hla')
|
||||
->and($booking->car_plate_number)->toBe('YGN-5678');
|
||||
});
|
||||
|
||||
test('a round trip: assigning a driver to the outbound leg does not touch the linked return leg', function () {
|
||||
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$return = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
(new AssignDriverAction)->handle($outbound, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
// Each leg has its own independent driver/vehicle slot — the return leg
|
||||
// can get a completely different (or no-yet-assigned) vehicle, per the
|
||||
// "next available vehicle" business rule (domain.md §2b).
|
||||
expect($outbound->refresh()->driver_name)->toBe('U Aung')
|
||||
->and($return->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
@@ -30,40 +30,52 @@ test('front seat and back seat can be selected together in one booking', functio
|
||||
]))->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('requesting more front seats than the configured max is rejected', function () {
|
||||
test('requesting more passengers than the configured max is rejected', function (VehicleOption $option) {
|
||||
config(["booking.{$option->value}_max_per_booking" => 1]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
})->with([
|
||||
'front_seat' => [VehicleOption::FrontSeat],
|
||||
'back_seat' => [VehicleOption::BackSeat],
|
||||
'whole_vehicle' => [VehicleOption::WholeVehicle],
|
||||
]);
|
||||
|
||||
test('requesting passengers up to the configured max passes', function (VehicleOption $option) {
|
||||
config(["booking.{$option->value}_max_per_booking" => 2]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)]))
|
||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
})->with([
|
||||
'front_seat' => [VehicleOption::FrontSeat],
|
||||
'back_seat' => [VehicleOption::BackSeat],
|
||||
'whole_vehicle' => [VehicleOption::WholeVehicle],
|
||||
]);
|
||||
|
||||
test('an option is rejected when disabled via config', function (VehicleOption $option) {
|
||||
config(["booking.{$option->value}_enabled" => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData($option)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class, "Vehicle option [{$option->value}] is not currently available for booking.");
|
||||
})->with([
|
||||
'front_seat' => [VehicleOption::FrontSeat],
|
||||
'back_seat' => [VehicleOption::BackSeat],
|
||||
'whole_vehicle' => [VehicleOption::WholeVehicle],
|
||||
]);
|
||||
|
||||
test('exceeding the max produces the expected message', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 1]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('requesting front seats up to the configured max passes', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 2]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('back seat is rejected when disabled via config', function () {
|
||||
config(['booking.back_seat_enabled' => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::BackSeat)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('whole vehicle is rejected when disabled via config', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::WholeVehicle)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
->toThrow(InvalidVehicleSelectionException::class, 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
|
||||
});
|
||||
|
||||
test('the same vehicle option cannot be selected twice in one booking', function () {
|
||||
|
||||
@@ -23,7 +23,9 @@ class EvCompanyFactory extends Factory
|
||||
'slug' => fake()->unique()->slug(),
|
||||
'description' => fake()->sentence(),
|
||||
'mm_description' => null,
|
||||
'contact' => fake()->phoneNumber(),
|
||||
// fake()->phoneNumber() occasionally emits formats (e.g. extensions like "x1234")
|
||||
// that fail the form's ->tel() regex validation, making the test flaky.
|
||||
'contact' => fake()->numerify('+959#########'),
|
||||
'address' => fake()->address(),
|
||||
'logo' => null,
|
||||
'is_active' => true,
|
||||
|
||||
@@ -23,7 +23,7 @@ class EvCompanyResource extends JsonResource
|
||||
'mm_description' => $this->mm_description,
|
||||
'contact' => $this->contact,
|
||||
'address' => $this->address,
|
||||
'logo' => $this->logo,
|
||||
'logo' => $this->logo_url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Modules\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Catalog\Database\Factories\EvCompanyFactory;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
@@ -72,4 +74,26 @@ class EvCompany extends Model
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `logo` is stored as the disk-relative path Filament's FileUpload
|
||||
* writes (e.g. "logos/xxx.png"), not a URL — API consumers need a full
|
||||
* absolute URL to render it directly. Guards against the disk itself
|
||||
* already returning an absolute URL (e.g. an s3 disk), so this stays
|
||||
* correct if the storage disk ever changes from local.
|
||||
*/
|
||||
public function logoUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function (): ?string {
|
||||
if (blank($this->logo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = Storage::disk(config('filesystems.default'))->url($this->logo);
|
||||
|
||||
return str($url)->startsWith(['http://', 'https://']) ? $url : url($url);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
|
||||
@@ -19,6 +20,24 @@ test('lists active ev companies', function () {
|
||||
->assertJsonFragment(['id' => $active->id]);
|
||||
});
|
||||
|
||||
test('returns the company logo as a full absolute url', function () {
|
||||
$company = EvCompany::factory()->create(['is_active' => true, 'logo' => 'logos/example.png']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful()
|
||||
->assertJsonFragment(['logo' => url(Storage::disk(config('filesystems.default'))->url($company->logo))]);
|
||||
});
|
||||
|
||||
test('returns a null logo when the company has none', function () {
|
||||
EvCompany::factory()->create(['is_active' => true, 'logo' => null]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful()
|
||||
->assertJsonFragment(['logo' => null]);
|
||||
});
|
||||
|
||||
test('lists active destinations', function () {
|
||||
$active = Destination::factory()->create(['is_active' => true]);
|
||||
Destination::factory()->create(['is_active' => false]);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "modules/cms",
|
||||
"description": "",
|
||||
"type": "library",
|
||||
"version": "1.0",
|
||||
"license": "proprietary",
|
||||
"require": {},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Cms\\": "src/",
|
||||
"Modules\\Cms\\Tests\\": "tests/",
|
||||
"Modules\\Cms\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\Cms\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Modules\\Cms\\Providers\\CmsServiceProvider"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Cms\Models\CmsPage;
|
||||
|
||||
/**
|
||||
* @extends Factory<CmsPage>
|
||||
*/
|
||||
class CmsPageFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'page' => fake()->unique()->slug(),
|
||||
'title' => fake()->sentence(3),
|
||||
'mm_title' => fake()->sentence(3),
|
||||
'meta_tags' => fake()->words(3, true),
|
||||
'meta_keywords' => fake()->words(3, true),
|
||||
'content' => fake()->paragraphs(3, true),
|
||||
'mm_content' => fake()->paragraphs(3, true),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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::create('cms_pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('page')->unique();
|
||||
$table->string('title');
|
||||
$table->string('mm_title');
|
||||
$table->string('meta_tags')->nullable();
|
||||
$table->string('meta_keywords')->nullable();
|
||||
$table->longText('content')->nullable();
|
||||
$table->longText('mm_content')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cms_pages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Cms\Http\Controllers\CmsPageController;
|
||||
|
||||
// CMS content (FAQ, About Us, Terms, etc.) is public and typically shown
|
||||
// before the user logs in, so unlike the catalog/routing endpoints this
|
||||
// group skips api.auth — throttle:api-read still rate-limits it by IP.
|
||||
Route::prefix('api/v1')->middleware(['api', 'throttle:api-read'])->group(function () {
|
||||
Route::get('/cms-pages', [CmsPageController::class, 'index'])->name('cms.pages.index');
|
||||
Route::get('/cms-pages/{page}', [CmsPageController::class, 'show'])->name('cms.pages.show');
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class CmsPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'cms';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: __DIR__.'/Filament/Resources',
|
||||
for: 'Modules\Cms\Filament\Resources',
|
||||
)
|
||||
->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\Cms\Filament\Pages',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: __DIR__.'/Filament/Widgets',
|
||||
for: 'Modules\Cms\Filament\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Filament\Resources\CmsPages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Pages\CreateCmsPage;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Pages\EditCmsPage;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Pages\ListCmsPages;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Schemas\CmsPageForm;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Tables\CmsPagesTable;
|
||||
use Modules\Cms\Models\CmsPage;
|
||||
use UnitEnum;
|
||||
|
||||
class CmsPageResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CmsPage::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'CMS';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return CmsPageForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CmsPagesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCmsPages::route('/'),
|
||||
'create' => CreateCmsPage::route('/create'),
|
||||
'edit' => EditCmsPage::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Filament\Resources\CmsPages\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\CmsPageResource;
|
||||
|
||||
class CreateCmsPage extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CmsPageResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Filament\Resources\CmsPages\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\CmsPageResource;
|
||||
|
||||
class EditCmsPage extends EditRecord
|
||||
{
|
||||
protected static string $resource = CmsPageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Filament\Resources\CmsPages\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\CmsPageResource;
|
||||
|
||||
class ListCmsPages extends ListRecords
|
||||
{
|
||||
protected static string $resource = CmsPageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Filament\Resources\CmsPages\Schemas;
|
||||
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CmsPageForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('meta_tags')
|
||||
->maxLength(255),
|
||||
TextInput::make('meta_keywords')
|
||||
->maxLength(255),
|
||||
TextInput::make('page')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255)
|
||||
->helperText('Unique key used to look up this page, e.g. "miniapp_faq".'),
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('mm_title')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
RichEditor::make('content')
|
||||
->columnSpanFull(),
|
||||
RichEditor::make('mm_content')
|
||||
->columnSpanFull(),
|
||||
Toggle::make('is_active')
|
||||
->required()
|
||||
->default(true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Filament\Resources\CmsPages\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CmsPagesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('page')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('title')
|
||||
->searchable(),
|
||||
TextColumn::make('mm_title')
|
||||
->searchable(),
|
||||
IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
TernaryFilter::make('is_active'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Cms\Http\Resources\CmsPageResource;
|
||||
use Modules\Cms\Models\CmsPage;
|
||||
|
||||
class CmsPageController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return CmsPageResource::collection(
|
||||
CmsPage::query()->where('is_active', true)->paginate()
|
||||
);
|
||||
}
|
||||
|
||||
public function show(string $page): CmsPageResource
|
||||
{
|
||||
$cmsPage = CmsPage::query()
|
||||
->where('page', $page)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
return new CmsPageResource($cmsPage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CmsPageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'page' => $this->page,
|
||||
'title' => $this->title,
|
||||
'mm_title' => $this->mm_title,
|
||||
'meta_tags' => $this->meta_tags,
|
||||
'meta_keywords' => $this->meta_keywords,
|
||||
'content' => $this->content,
|
||||
'mm_content' => $this->mm_content,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Cms\Database\Factories\CmsPageFactory;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class CmsPage extends Model
|
||||
{
|
||||
/** @use HasFactory<CmsPageFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — CMS content writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('cms');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'page',
|
||||
'title',
|
||||
'mm_title',
|
||||
'meta_tags',
|
||||
'meta_keywords',
|
||||
'content',
|
||||
'mm_content',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Cms\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CmsServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void {}
|
||||
|
||||
public function boot(): void {}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Pages\CreateCmsPage;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Pages\EditCmsPage;
|
||||
use Modules\Cms\Filament\Resources\CmsPages\Pages\ListCmsPages;
|
||||
use Modules\Cms\Models\CmsPage;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
use function Pest\Laravel\assertDatabaseHas;
|
||||
|
||||
beforeEach(function () {
|
||||
Role::findOrCreate('admin', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->assignRole('admin');
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('can list cms pages', function () {
|
||||
$pages = CmsPage::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListCmsPages::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($pages);
|
||||
});
|
||||
|
||||
test('can create a cms page', function () {
|
||||
$page = CmsPage::factory()->make();
|
||||
|
||||
Livewire::test(CreateCmsPage::class)
|
||||
->fillForm([
|
||||
'page' => $page->page,
|
||||
'title' => $page->title,
|
||||
'mm_title' => $page->mm_title,
|
||||
'meta_tags' => $page->meta_tags,
|
||||
'meta_keywords' => $page->meta_keywords,
|
||||
'content' => $page->content,
|
||||
'mm_content' => $page->mm_content,
|
||||
'is_active' => true,
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(CmsPage::class, [
|
||||
'page' => $page->page,
|
||||
'title' => $page->title,
|
||||
]);
|
||||
});
|
||||
|
||||
test('can edit a cms page', function () {
|
||||
$page = CmsPage::factory()->create();
|
||||
|
||||
Livewire::test(EditCmsPage::class, ['record' => $page->getRouteKey()])
|
||||
->assertOk()
|
||||
->fillForm(['title' => 'Updated Title'])
|
||||
->call('save')
|
||||
->assertNotified();
|
||||
|
||||
assertDatabaseHas(CmsPage::class, [
|
||||
'id' => $page->id,
|
||||
'title' => 'Updated Title',
|
||||
]);
|
||||
});
|
||||
|
||||
test('requires a unique page slug', function () {
|
||||
CmsPage::factory()->create(['page' => 'miniapp_faq']);
|
||||
$page = CmsPage::factory()->make(['page' => 'miniapp_faq']);
|
||||
|
||||
Livewire::test(CreateCmsPage::class)
|
||||
->fillForm([
|
||||
'page' => $page->page,
|
||||
'title' => $page->title,
|
||||
'mm_title' => $page->mm_title,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['page' => 'unique']);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Modules\Cms\Models\CmsPage;
|
||||
|
||||
test('lists active cms pages', function () {
|
||||
$active = CmsPage::factory()->create(['is_active' => true]);
|
||||
CmsPage::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->getJson('/api/v1/cms-pages')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonFragment(['id' => $active->id]);
|
||||
});
|
||||
|
||||
test('does not require authentication', function () {
|
||||
CmsPage::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->getJson('/api/v1/cms-pages')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('shows an active cms page by its page slug', function () {
|
||||
$page = CmsPage::factory()->create(['page' => 'miniapp_faq', 'is_active' => true]);
|
||||
|
||||
$this->getJson('/api/v1/cms-pages/miniapp_faq')
|
||||
->assertSuccessful()
|
||||
->assertJsonFragment(['id' => $page->id, 'page' => 'miniapp_faq']);
|
||||
});
|
||||
|
||||
test('returns not found for an inactive page', function () {
|
||||
CmsPage::factory()->create(['page' => 'miniapp_faq', 'is_active' => false]);
|
||||
|
||||
$this->getJson('/api/v1/cms-pages/miniapp_faq')->assertNotFound();
|
||||
});
|
||||
|
||||
test('returns not found for an unknown page slug', function () {
|
||||
$this->getJson('/api/v1/cms-pages/unknown-page')->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Identity\Models\RegistrationVerification;
|
||||
|
||||
/**
|
||||
* @extends Factory<RegistrationVerification>
|
||||
*/
|
||||
class RegistrationVerificationFactory extends Factory
|
||||
{
|
||||
protected $model = RegistrationVerification::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => fake()->unique()->safeEmail(),
|
||||
'type' => 'email',
|
||||
'code' => Hash::make('123456'),
|
||||
'attempts' => 0,
|
||||
'verified_at' => null,
|
||||
'verification_token' => null,
|
||||
'consumed_at' => null,
|
||||
'expires_at' => now()->addMinutes(10),
|
||||
];
|
||||
}
|
||||
|
||||
public function phone(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'identifier' => fake()->numerify('+959#########'),
|
||||
'type' => 'phone',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verified(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'verified_at' => now(),
|
||||
'verification_token' => str()->random(64),
|
||||
]);
|
||||
}
|
||||
|
||||
public function expired(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?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) {
|
||||
$table->string('phone')->nullable()->unique()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('phone');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?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::create('registration_verifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('identifier')->unique();
|
||||
$table->string('type');
|
||||
$table->string('code');
|
||||
$table->unsignedTinyInteger('attempts')->default(0);
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->string('verification_token')->nullable()->unique();
|
||||
$table->timestamp('consumed_at')->nullable();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('registration_verifications');
|
||||
}
|
||||
};
|
||||
@@ -25,6 +25,8 @@ class RolePermissionSeeder extends Seeder
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'manage_ai_agent',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,8 @@ class RolePermissionSeeder extends Seeder
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'manage_ai_agent',
|
||||
],
|
||||
'admin' => [
|
||||
'manage_catalog',
|
||||
@@ -56,6 +60,8 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'manage_ai_agent',
|
||||
],
|
||||
'support' => [
|
||||
'view_bookings',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<x-mail::message>
|
||||
# Verification Code
|
||||
|
||||
Use the code below to confirm your account:
|
||||
|
||||
<x-mail::panel>
|
||||
{{ $code }}
|
||||
</x-mail::panel>
|
||||
|
||||
This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
@@ -1,8 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Identity\Http\Controllers\RegistrationController;
|
||||
use Modules\Identity\Http\Controllers\TokenController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'throttle:api-auth'])->group(function () {
|
||||
Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token');
|
||||
|
||||
Route::post('/auth/registration/request-code', [RegistrationController::class, 'requestCode'])
|
||||
->middleware('throttle:api-otp')
|
||||
->name('identity.auth.registration.request-code');
|
||||
Route::post('/auth/registration/verify-code', [RegistrationController::class, 'verifyCode'])
|
||||
->name('identity.auth.registration.verify-code');
|
||||
Route::post('/auth/register', [RegistrationController::class, 'store'])
|
||||
->name('identity.auth.register');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Identity\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -58,9 +60,17 @@ class ManageAppSettings extends Page
|
||||
'support_phone' => config('app.support_phone'),
|
||||
'timezone' => config('app.timezone'),
|
||||
'currency' => config('app.currency'),
|
||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||
'front_seat_enabled' => (bool) config('booking.front_seat_enabled'),
|
||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||
'back_seat_max_per_booking' => config('booking.back_seat_max_per_booking'),
|
||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||
'whole_vehicle_max_per_booking' => config('booking.whole_vehicle_max_per_booking'),
|
||||
'booking_admin_emails' => config('booking.admin_emails'),
|
||||
'sms_enabled' => (bool) config('services.sms.enabled'),
|
||||
'sms_server' => config('services.sms.sms_poh.server'),
|
||||
'sms_token' => config('services.sms.sms_poh.token'),
|
||||
'sms_sender' => config('services.sms.sms_poh.sender'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -99,19 +109,62 @@ class ManageAppSettings extends Page
|
||||
->columns(2),
|
||||
Tab::make('Booking')
|
||||
->schema([
|
||||
Toggle::make('back_seat_enabled')
|
||||
->label('Back Seat Enabled')
|
||||
->helperText('Whether customers can select Back Seat at all right now.'),
|
||||
Toggle::make('whole_vehicle_enabled')
|
||||
->label('Whole Vehicle Enabled')
|
||||
->helperText('Whether customers can select Whole Vehicle at all right now.'),
|
||||
Toggle::make('front_seat_enabled')
|
||||
->label('Front Seat Enabled')
|
||||
->helperText('Whether customers can select Front Seat at all right now.'),
|
||||
TextInput::make('front_seat_max_per_booking')
|
||||
->label('Front Seat Max Per Booking')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Front Seats a single booking may request.'),
|
||||
]),
|
||||
Toggle::make('back_seat_enabled')
|
||||
->label('Back Seat Enabled')
|
||||
->helperText('Whether customers can select Back Seat at all right now.'),
|
||||
TextInput::make('back_seat_max_per_booking')
|
||||
->label('Back Seat Max Per Booking')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Back Seats a single booking may request.'),
|
||||
Toggle::make('whole_vehicle_enabled')
|
||||
->label('Whole Vehicle Enabled')
|
||||
->helperText('Whether customers can select Whole Vehicle at all right now.'),
|
||||
TextInput::make('whole_vehicle_max_per_booking')
|
||||
->label('Whole Vehicle Max Per Booking')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Whole Vehicle passenger count a single booking may request.'),
|
||||
TagsInput::make('booking_admin_emails')
|
||||
->label('Admin Emails')
|
||||
->required()
|
||||
->helperText('Notified on booking events. Press enter after each address.'),
|
||||
])
|
||||
->columns(2),
|
||||
Tab::make('SMS')
|
||||
->schema([
|
||||
Toggle::make('sms_enabled')
|
||||
->label('SMS Enabled')
|
||||
->live()
|
||||
->helperText('Whether driver/car SMS notifications are sent at all.'),
|
||||
TextInput::make('sms_server')
|
||||
->label('SMS Server URL')
|
||||
->url()
|
||||
->maxLength(255)
|
||||
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||
TextInput::make('sms_token')
|
||||
->label('SMS Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(255)
|
||||
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||
TextInput::make('sms_sender')
|
||||
->label('SMS Sender')
|
||||
->maxLength(255)
|
||||
->helperText('Default sender name/number for outgoing SMS.'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
])
|
||||
->livewireSubmitHandler('save')
|
||||
@@ -136,9 +189,17 @@ class ManageAppSettings extends Page
|
||||
'SUPPORT_PHONE' => $state['support_phone'],
|
||||
'APP_TIMEZONE' => $state['timezone'],
|
||||
'APP_CURRENCY' => $state['currency'],
|
||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||
'BOOKING_FRONT_SEAT_ENABLED' => (bool) $state['front_seat_enabled'],
|
||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||
'BOOKING_BACK_SEAT_MAX_PER_BOOKING' => (int) $state['back_seat_max_per_booking'],
|
||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||
'BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING' => (int) $state['whole_vehicle_max_per_booking'],
|
||||
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
|
||||
'SMS_ENABLED' => (bool) $state['sms_enabled'],
|
||||
'SMS_SERVER' => $state['sms_server'],
|
||||
'SMS_TOKEN' => $state['sms_token'],
|
||||
'SMS_SENDER' => $state['sms_sender'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Identity\Enums\TokenAbility;
|
||||
use Modules\Identity\Http\Requests\RegisterRequest;
|
||||
use Modules\Identity\Http\Requests\RequestRegistrationCodeRequest;
|
||||
use Modules\Identity\Http\Requests\VerifyRegistrationCodeRequest;
|
||||
use Modules\Identity\Models\RegistrationVerification;
|
||||
|
||||
/**
|
||||
* Confirm-first registration (mini app / mobile app): request a code for an
|
||||
* email or phone, verify it, then complete registration with the
|
||||
* verification_token that step returns. Kept as three actions on one
|
||||
* controller since they're steps of a single flow sharing the same model.
|
||||
*/
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
public function requestCode(RequestRegistrationCodeRequest $request): array
|
||||
{
|
||||
RegistrationVerification::issueFor($request->string('identifier')->toString(), $request->identifierType());
|
||||
|
||||
return ['message' => 'A verification code has been sent.'];
|
||||
}
|
||||
|
||||
public function verifyCode(VerifyRegistrationCodeRequest $request): array
|
||||
{
|
||||
$verification = RegistrationVerification::where('identifier', $request->string('identifier'))->first();
|
||||
|
||||
if (! $verification || $verification->isExpired()) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['This code has expired. Please request a new one.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $verification->attemptVerify($request->string('code')->toString())) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['The provided code is incorrect.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return ['verification_token' => $verification->verification_token];
|
||||
}
|
||||
|
||||
public function store(RegisterRequest $request): array
|
||||
{
|
||||
$verification = RegistrationVerification::where('verification_token', $request->string('verification_token'))->first();
|
||||
|
||||
if (! $verification || ! $verification->isVerified() || $verification->isConsumed() || $verification->isExpired()) {
|
||||
throw ValidationException::withMessages([
|
||||
'verification_token' => ['This verification has expired or was already used. Please start again.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->string('name'),
|
||||
'email' => $verification->type === 'email' ? $verification->identifier : null,
|
||||
'phone' => $verification->type === 'phone' ? $verification->identifier : null,
|
||||
'password' => $request->string('password'),
|
||||
]);
|
||||
|
||||
$verification->update(['consumed_at' => now()]);
|
||||
|
||||
$token = $user->createToken(
|
||||
$request->string('device_name')->toString(),
|
||||
TokenAbility::customerAbilities(),
|
||||
);
|
||||
|
||||
return [
|
||||
'token' => $token->plainTextToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'verification_token' => ['required', 'string'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
'device_name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RequestRegistrationCodeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "email" or "phone" — the identifier's format determines the channel
|
||||
* the code is delivered over.
|
||||
*/
|
||||
public function identifierType(): string
|
||||
{
|
||||
return filter_var($this->string('identifier'), FILTER_VALIDATE_EMAIL) !== false ? 'email' : 'phone';
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$identifier = $this->string('identifier')->toString();
|
||||
|
||||
if ($this->identifierType() === 'phone' && ! preg_match('/^\+?[0-9]{7,15}$/', $identifier)) {
|
||||
$validator->errors()->add('identifier', 'The identifier must be a valid email address or phone number.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$column = $this->identifierType() === 'email' ? 'email' : 'phone';
|
||||
|
||||
if (User::where($column, $identifier)->exists()) {
|
||||
$validator->errors()->add('identifier', 'An account with this '.$column.' already exists.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class VerifyRegistrationCodeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => ['required', 'string'],
|
||||
'code' => ['required', 'string', 'size:6'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RegistrationCodeMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly string $code) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
return $this
|
||||
->subject(config('app.name').' - Verification Code')
|
||||
->view('identity::mail.registration-code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Identity\Database\Factories\RegistrationVerificationFactory;
|
||||
use Modules\Identity\Mail\RegistrationCodeMail;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
class RegistrationVerification extends Model
|
||||
{
|
||||
/** @use HasFactory<RegistrationVerificationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* A code is only good for this long — kept short since it's delivered
|
||||
* over email/SMS and re-requesting a fresh one is cheap (throttled by
|
||||
* the api-otp rate limiter).
|
||||
*/
|
||||
private const CODE_LIFETIME_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* Wrong-code guesses allowed before the code is locked out and a fresh
|
||||
* one must be requested.
|
||||
*/
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'identifier',
|
||||
'type',
|
||||
'code',
|
||||
'attempts',
|
||||
'verified_at',
|
||||
'verification_token',
|
||||
'consumed_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'verified_at' => 'datetime',
|
||||
'consumed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a fresh code for the identifier and delivers it over
|
||||
* email or SMS, replacing any previous pending verification for the
|
||||
* same identifier (resend just supersedes the old code).
|
||||
*/
|
||||
public static function issueFor(string $identifier, string $type): self
|
||||
{
|
||||
$code = (string) random_int(100000, 999999);
|
||||
|
||||
$verification = self::query()->updateOrCreate(
|
||||
['identifier' => $identifier],
|
||||
[
|
||||
'type' => $type,
|
||||
'code' => Hash::make($code),
|
||||
'attempts' => 0,
|
||||
'verified_at' => null,
|
||||
'verification_token' => null,
|
||||
'consumed_at' => null,
|
||||
'expires_at' => now()->addMinutes(self::CODE_LIFETIME_MINUTES),
|
||||
],
|
||||
);
|
||||
|
||||
$verification->deliver($code);
|
||||
|
||||
return $verification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the given code against this pending verification. On success,
|
||||
* marks it verified and issues the one-time token step 3 (registration)
|
||||
* will need to complete the flow.
|
||||
*/
|
||||
public function attemptVerify(string $code): bool
|
||||
{
|
||||
if ($this->isExpired() || $this->attempts >= self::MAX_ATTEMPTS || ! Hash::check($code, $this->code)) {
|
||||
$this->increment('attempts');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'verified_at' => now(),
|
||||
'verification_token' => Str::random(64),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isVerified(): bool
|
||||
{
|
||||
return $this->verified_at !== null;
|
||||
}
|
||||
|
||||
public function isConsumed(): bool
|
||||
{
|
||||
return $this->consumed_at !== null;
|
||||
}
|
||||
|
||||
private function deliver(string $code): void
|
||||
{
|
||||
if ($this->type === 'email') {
|
||||
Mail::to($this->identifier)->send(new RegistrationCodeMail($code));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$appName = config('app.name');
|
||||
app(SmsService::class)->send(
|
||||
$this->identifier,
|
||||
"{$appName}: Your verification code is {$code}. It expires in ".self::CODE_LIFETIME_MINUTES.' minutes.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use Modules\Booking\Models\Booking;
|
||||
use Modules\Identity\Enums\TokenAbility;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
/**
|
||||
* T6.4 — full policy + agent-ability audit (domain.md §8). The FastAPI
|
||||
@@ -63,17 +64,34 @@ test('catalog writes have no customer-facing route at all', function () {
|
||||
});
|
||||
|
||||
test('routing/pricing writes have no customer-facing route at all', function () {
|
||||
// Only a read-only search endpoint exists for EvRoute — no create/update/
|
||||
// delete route was ever registered, and the search endpoint itself
|
||||
// never creates records regardless of payload (it's POST because
|
||||
// round_trip returns two result sets, not because it writes anything).
|
||||
// {route} only has a GET (show) handler registered, so PUT/DELETE hit
|
||||
// that same URI pattern and are rejected as 405 (method not allowed).
|
||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||
->postJson('/api/v1/routes', ['ev_company_id' => 1])
|
||||
->putJson('/api/v1/routes/1', ['ev_company_id' => 1])
|
||||
->assertStatus(405);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||
->deleteJson('/api/v1/routes/1')
|
||||
->assertStatus(405);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||
->postJson('/api/v1/routes/search', ['ev_company_id' => 1])
|
||||
->assertSuccessful();
|
||||
|
||||
expect(EvRoute::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('the agent token can still read routes and create/read bookings', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||
->getJson('/api/v1/routes')
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertSuccessful();
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->agent->id]);
|
||||
Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||
->getJson('/api/v1/bookings')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user