phase2(deal-show): GET /api/deals/{id} + DealDetailDrawer на реальный ActivityLog
Закрывает gap «timeline в drawer'е показывает hard-coded MOCK_EVENTS» —
теперь drawer fetch'ит реальные activity-events на open из tenant-filtered
activity_log. Без tenant_id — fallback на MOCK_EVENTS как раньше.
Backend (DealController::show):
- GET /api/deals/{id}?tenant_id={id} — возвращает {deal, events}.
- Deal extended (project_name, manager_name/initials, comment, assigned_at).
- Events — последние 50 записей activity_log по (tenant_id, deal_id)
ORDER BY created_at DESC, с актором (user через belongsTo).
- RLS-обёртка + defense-in-depth where(tenant_id) — 404 если чужая.
Pest +8 (DealShowTest):
- 422/404 базовые / 404 чужая сделка / deal-relations / events ORDER BY +
actor + actor=null для system-event / RLS+app-фильтр изоляция событий /
лимит 50 событий.
Frontend:
- api/deals.ts::getDeal — типизированный helper c ApiDealEvent/Detail/Response.
- composables/dealsApiMapper.ts::mapApiDealEvent — converter ApiDealEvent →
DealEvent: clamp event-slug на known types с fallback на 'deal.viewed';
detail зависит от type (status_changed: 'from → to'; created: source;
остальные: JSON-сводка context).
- DealDetailDrawer: optional tenantId prop, watch([open, deal.id, tenantId])
с immediate=true → loadEvents() на open. Reject → eventsFetchError +
v-alert warning + MOCK_EVENTS fallback.
- DealsView/KanbanView передают :tenant-id="auth.user?.tenant_id".
Vitest +4 (DealDetailDrawerApi.spec.ts):
- Без tenantId — getDeal не вызывается + MOCK_EVENTS видны.
- С tenantId — getDeal + events замещены + 'new → paid' виден.
- reject → fetchError + alert + MOCK_EVENTS fallback.
- open=false → getDeal не вызывается.
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 273/273 за 20.76 сек (+4 от 269).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 205/205 за 24.19 сек (+8 от 197, 812 assertions).
Реестр v1.62→v1.63 / CLAUDE.md v1.53→v1.54.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,92 @@ class DealController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/deals/{id}?tenant_id={id} — детали сделки + recent activity events.
|
||||
*
|
||||
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
|
||||
* relations + до 50 последних activity_log событий по этой сделке.
|
||||
*
|
||||
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
|
||||
* принадлежит tenant'у (или не существует) — 404.
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$deal = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $id)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name'])
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$events = ActivityLog::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deal_id', $id)
|
||||
->with('user:id,email,first_name,last_name')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return [$deal, $events];
|
||||
});
|
||||
|
||||
if ($deal === null) {
|
||||
return response()->json(['message' => 'Сделка не найдена.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
'tenant_id' => $deal->tenant_id,
|
||||
'project_id' => $deal->project_id,
|
||||
'project_name' => $deal->project?->name,
|
||||
'phone' => $deal->phone,
|
||||
'contact_name' => $deal->contact_name,
|
||||
'comment' => $deal->comment,
|
||||
'status' => $deal->status,
|
||||
'manager_id' => $deal->manager_id,
|
||||
'manager_name' => $deal->manager
|
||||
? ManagerController::formatName($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email)
|
||||
: null,
|
||||
'manager_initials' => $deal->manager
|
||||
? ManagerController::formatInitials($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email)
|
||||
: null,
|
||||
'received_at' => $deal->received_at?->toIso8601String(),
|
||||
'assigned_at' => $deal->assigned_at?->toIso8601String(),
|
||||
],
|
||||
'events' => $events->map(fn (ActivityLog $e) => [
|
||||
'id' => $e->id,
|
||||
'event' => $e->event,
|
||||
'context' => $e->context,
|
||||
'created_at' => $e->created_at?->toIso8601String(),
|
||||
'actor' => $e->user
|
||||
? [
|
||||
'id' => $e->user->id,
|
||||
'name' => ManagerController::formatName($e->user->first_name, $e->user->last_name, $e->user->email),
|
||||
'initials' => ManagerController::formatInitials($e->user->first_name, $e->user->last_name, $e->user->email),
|
||||
]
|
||||
: null,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/deals/transition — bulk status-update для выбранных сделок.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user