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