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,
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
}
|