phase2(restore-flow): POST /api/deals/restore + undo-snackbar (этап 6 — completion of stage 5)
Soft-delete был half-done: пользователь не мог отменить случайное удаление.
Теперь после bulk-delete показывается snackbar «Удалено N · Восстановить»
на 8 секунд.
Backend (DealController::restore):
- POST /api/deals/restore {tenant_id, ids: [1..1000 ints]}.
- withTrashed() обходит global scope SoftDeletes + явный
whereNotNull('deleted_at') для NO-OP idempotency на живых.
- RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.restored, context.source='bulk' для каждой
ВОССТАНОВЛЕННОЙ. Константа EVENT_DEAL_RESTORED добавлена в модель.
Pest +7 (DealRestoreTest):
- 422/404 базовые / soft-delete + restore + audit / NO-OP на живых
не пишет audit / defense-in-depth (свой restored, чужой остался) /
после restore видна в GET /api/deals / 422 пустой массив.
Frontend:
- dealsApi.bulkRestoreDeals — POST-helper.
- DealsView::applyBulkDelete: snapshot удалённых сделок (deep-clone
manager.*) сохраняется в lastDeletedSnapshot ref.
- undoBulkDelete() async: optimistic re-insert + bulkRestoreDeals если
auth.user; success → toast «Восстановлено N»; fail → warning.
- v-snackbar bulk-delete: 3→8 сек timeout + #actions слот с кнопкой
«Восстановить» (показ только при snapshot.length > 0). После undo
snapshot очищается → кнопка пропадает.
Vitest +3 (DealsListIntegration):
- bulk-delete + undo восстанавливает обе + bulkRestoreDeals + cleanup
snapshot.
- Undo без tenant_id — НЕ вызывает API + только локально.
- Undo reject → warning toast + локальное восстановление остаётся.
PHPStan baseline регенерирован. cspell-glossary +unshift +партиальный.
Регресс:
- Lint+type-check+format passed.
- Vitest 311/311 за 18.71 сек (+3 от 308).
- Vite build 877 ms.
- Pint + PHPStan passed.
- Pest 263/263 за 27.68 сек (+7 от 256, 998 assertions).
Реестр v1.69→v1.70 / CLAUDE.md v1.60→v1.61.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -496,6 +496,68 @@ class DealController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/deals/restore — bulk restore soft-deleted сделок.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Используется в DealsView undo-snackbar после applyBulkDelete (UX:
|
||||
* пользователь нажал «Восстановить» в течение 8 сек после удаления).
|
||||
*
|
||||
* Соединяет SoftDeletes.withTrashed() с явным `whereNotNull('deleted_at')`
|
||||
* — restore только удалённых сделок (NO-OP idempotency для уже живых).
|
||||
*
|
||||
* RLS + defense-in-depth where(tenant_id) → партиальный update только своих.
|
||||
* ActivityLog event=deal.restored для каждой ВОССТАНОВЛЕННОЙ сделки.
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$restored = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// withTrashed() обходит global scope SoftDeletes; явный
|
||||
// whereNotNull — для NO-OP idempotency на уже живых сделках.
|
||||
$deals = Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNotNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($deals as $deal) {
|
||||
$deal->deleted_at = null;
|
||||
$deal->save();
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_RESTORED,
|
||||
'context' => ['source' => 'bulk'],
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'restored' => $restored,
|
||||
'requested' => count($validated['ids']),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/deals — manual create */
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user