Files
portal/app/app/Http/Controllers/Api/ReminderController.php
T
Дмитрий 39b6127bce phase2(reminders-backend): CRUD + cron-диспетчер + email/inapp (P0 этап 4)
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.

Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
  GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
    окно ±1 день, counts для UI badges);
  POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
  PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
  POST {id}/complete (idempotent);
  DELETE {id}.
  RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
  TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
  ?? created_by (если active+!deleted). Каналы email+inapp по prefs.
  payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
  {--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
  LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
  ставит is_sent=true даже если recipient deactivated (защита от retry-spam).

Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
  store + FK guard / update text+remind_at сбрасывает is_sent / complete
  idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
  skip / уже sent / assignee вместо created_by / deactivated user (is_sent
  всё равно) / только inapp при email=false / --dry-run / --limit / RLS.

PHPStan baseline регенерирован. IDE-helper для всех моделей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:30:38 +03:00

304 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Reminder;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Reminders API (schema v8.10 §17.5). Все endpoint'ы под `auth:sanctum`.
*
* Фильтры filter= для GET /api/reminders:
* today — completed_at IS NULL AND remind_at в (now-1d, now+1d)
* upcoming — completed_at IS NULL AND remind_at > now+1d
* overdue — completed_at IS NULL AND remind_at < now-1d
* completed — completed_at IS NOT NULL
* active — completed_at IS NULL (default)
*
* RLS: внутри транзакции SET LOCAL app.current_tenant_id = $user->tenant_id.
* Защита от кражи: явный where('tenant_id', $user->tenant_id) поверх RLS.
*/
class ReminderController extends Controller
{
private const FILTERS = ['active', 'today', 'upcoming', 'overdue', 'completed'];
/**
* GET /api/reminders?filter=&deal_id=&limit=
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'filter' => 'nullable|string|in:'.implode(',', self::FILTERS),
'deal_id' => 'nullable|integer|min:1',
'limit' => 'nullable|integer|min:1|max:200',
]);
/** @var User $user */
$user = $request->user();
$filter = $validated['filter'] ?? 'active';
$limit = (int) ($validated['limit'] ?? 100);
return DB::transaction(function () use ($user, $filter, $validated, $limit): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$query = Reminder::query()
->with('creator:id,email,first_name,last_name')
->where('tenant_id', $user->tenant_id);
if (isset($validated['deal_id'])) {
$query->where('deal_id', (int) $validated['deal_id']);
}
$now = Carbon::now();
switch ($filter) {
case 'today':
$query->whereNull('completed_at')
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()]);
break;
case 'upcoming':
$query->whereNull('completed_at')
->where('remind_at', '>', $now->copy()->addDay());
break;
case 'overdue':
$query->whereNull('completed_at')
->where('remind_at', '<', $now->copy()->subDay());
break;
case 'completed':
$query->whereNotNull('completed_at');
break;
case 'active':
default:
$query->whereNull('completed_at');
break;
}
$items = $query->orderBy('remind_at')->limit($limit)->get();
// Counters для UI badges (today/upcoming/overdue) — отдельные SELECT'ы.
$base = Reminder::query()->where('tenant_id', $user->tenant_id);
$counts = [
'today' => (clone $base)->whereNull('completed_at')
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()])
->count(),
'upcoming' => (clone $base)->whereNull('completed_at')
->where('remind_at', '>', $now->copy()->addDay())
->count(),
'overdue' => (clone $base)->whereNull('completed_at')
->where('remind_at', '<', $now->copy()->subDay())
->count(),
'active' => (clone $base)->whereNull('completed_at')->count(),
];
return response()->json([
'items' => $items->map(fn (Reminder $r) => $this->toResource($r))->all(),
'counts' => $counts,
]);
});
}
/**
* POST /api/reminders {deal_id, text?, remind_at}.
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'deal_id' => 'required|integer|min:1',
'text' => 'nullable|string|max:255',
'remind_at' => 'required|date',
'assignee_id' => 'nullable|integer|min:1',
]);
/** @var User $user */
$user = $request->user();
// Manager FK guard для assignee_id: должен принадлежать тому же tenant'у.
if (isset($validated['assignee_id'])) {
$exists = User::query()
->where('id', $validated['assignee_id'])
->where('tenant_id', $user->tenant_id)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
if (! $exists) {
return response()->json([
'message' => 'Менеджер не найден в этом тенанте.',
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
], 422);
}
}
return DB::transaction(function () use ($user, $validated): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$reminder = Reminder::create([
'tenant_id' => $user->tenant_id,
'deal_id' => (int) $validated['deal_id'],
'text' => $validated['text'] ?? null,
'remind_at' => Carbon::parse($validated['remind_at']),
'created_by' => $user->id,
'assignee_id' => $validated['assignee_id'] ?? null,
'is_sent' => false,
]);
return response()->json([
'reminder' => $this->toResource($reminder->load('creator:id,email,first_name,last_name')),
], 201);
});
}
/**
* PATCH /api/reminders/{id} {text?, remind_at?, assignee_id?}.
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'text' => 'nullable|string|max:255',
'remind_at' => 'nullable|date',
'assignee_id' => 'nullable|integer|min:1',
]);
if (count($validated) === 0) {
return response()->json([
'message' => 'Передайте хотя бы одно поле.',
'errors' => ['_general' => ['Нужно хотя бы одно поле для обновления.']],
], 422);
}
/** @var User $user */
$user = $request->user();
if (isset($validated['assignee_id'])) {
$exists = User::query()
->where('id', $validated['assignee_id'])
->where('tenant_id', $user->tenant_id)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
if (! $exists) {
return response()->json([
'message' => 'Менеджер не найден.',
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
], 422);
}
}
return DB::transaction(function () use ($user, $id, $validated): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$reminder = Reminder::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($reminder === null) {
return response()->json(['message' => 'Напоминание не найдено.'], 404);
}
$update = [];
if (array_key_exists('text', $validated)) {
$update['text'] = $validated['text'];
}
if (isset($validated['remind_at'])) {
$update['remind_at'] = Carbon::parse($validated['remind_at']);
// При сдвиге remind_at сбрасываем is_sent, чтобы cron смог
// снова отправить уведомление к новому времени.
$update['is_sent'] = false;
$update['sent_at'] = null;
}
if (array_key_exists('assignee_id', $validated)) {
$update['assignee_id'] = $validated['assignee_id'];
}
$reminder->update($update);
return response()->json([
'reminder' => $this->toResource($reminder->fresh('creator')),
]);
});
}
/**
* POST /api/reminders/{id}/complete — пометить выполненным.
* Идемпотентно: повторный вызов NO-OP.
*/
public function complete(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$reminder = Reminder::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($reminder === null) {
return response()->json(['message' => 'Напоминание не найдено.'], 404);
}
if ($reminder->completed_at === null) {
$reminder->update(['completed_at' => Carbon::now()]);
}
return response()->json([
'reminder' => $this->toResource($reminder->fresh('creator')),
]);
});
}
/**
* DELETE /api/reminders/{id}.
*/
public function destroy(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$deleted = Reminder::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->delete();
if ($deleted === 0) {
return response()->json(['message' => 'Напоминание не найдено.'], 404);
}
return response()->json(['message' => 'Удалено.']);
});
}
/** @return array<string, mixed> */
private function toResource(Reminder $reminder): array
{
$creator = $reminder->creator;
return [
'id' => $reminder->id,
'deal_id' => $reminder->deal_id,
'text' => $reminder->text,
'remind_at' => $reminder->remind_at?->toIso8601String(),
'completed_at' => $reminder->completed_at?->toIso8601String(),
'is_sent' => $reminder->is_sent,
'sent_at' => $reminder->sent_at?->toIso8601String(),
'created_at' => $reminder->created_at?->toIso8601String(),
'created_by' => $reminder->created_by,
'assignee_id' => $reminder->assignee_id,
'creator_name' => $creator
? trim(($creator->first_name ?? '').' '.($creator->last_name ?? '')) ?: $creator->email
: null,
];
}
}