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>
89 lines
2.5 KiB
PHP
89 lines
2.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Models;
|
||
|
||
use Database\Factories\ReminderFactory;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
/**
|
||
* Напоминание на сделке (schema v8.10 §17.5).
|
||
*
|
||
* Tenant-aware модель с RLS. Композитные индексы:
|
||
* - idx_reminders_due (remind_at) WHERE is_sent=FALSE AND completed_at IS NULL — cron;
|
||
* - idx_reminders_deal (deal_id) — UI карточки сделки;
|
||
* - idx_reminders_tenant_user_active — дашборд «today/last/future».
|
||
*
|
||
* deal_id БЕЗ FK (deals партиционирована, FK на partitioned-родительскую
|
||
* таблицу не поддерживается без partition-key в составе).
|
||
*
|
||
* MVP: assignee_id всегда NULL — паритет с histories[].to оригинала. Поле
|
||
* зарезервировано для Post-MVP (multi-assignee).
|
||
*
|
||
* @mixin IdeHelperReminder
|
||
*/
|
||
class Reminder extends Model
|
||
{
|
||
/** @use HasFactory<ReminderFactory> */
|
||
use HasFactory;
|
||
|
||
protected $fillable = [
|
||
'tenant_id',
|
||
'deal_id',
|
||
'text',
|
||
'remind_at',
|
||
'created_by',
|
||
'assignee_id',
|
||
'completed_at',
|
||
'is_sent',
|
||
'sent_at',
|
||
];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'tenant_id' => 'integer',
|
||
'deal_id' => 'integer',
|
||
'created_by' => 'integer',
|
||
'assignee_id' => 'integer',
|
||
'is_sent' => 'boolean',
|
||
'remind_at' => 'datetime',
|
||
'completed_at' => 'datetime',
|
||
'sent_at' => 'datetime',
|
||
'created_at' => 'datetime',
|
||
'updated_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
/** @return BelongsTo<Tenant, $this> */
|
||
public function tenant(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Tenant::class);
|
||
}
|
||
|
||
/** @return BelongsTo<User, $this> */
|
||
public function creator(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class, 'created_by');
|
||
}
|
||
|
||
/** @return BelongsTo<User, $this> */
|
||
public function assignee(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class, 'assignee_id');
|
||
}
|
||
|
||
public function isCompleted(): bool
|
||
{
|
||
return $this->completed_at !== null;
|
||
}
|
||
|
||
public function isOverdue(): bool
|
||
{
|
||
return $this->completed_at === null && $this->remind_at < now();
|
||
}
|
||
}
|