39b6127bce
Закрыт пункт «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>
304 lines
11 KiB
PHP
304 lines
11 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|