Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
5718 lines
218 KiB
Markdown
5718 lines
218 KiB
Markdown
# Hado Core Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the Hado notification core: clients push dated events, the core computes state, cascade, windows and presence-aware delivery through web, Telegram and webhook channels.
|
||
|
||
**Architecture:** A Laravel 13 application in `core/` with Postgres. Pure domain classes in `app/Scheduling` (due resolution, cascade, state, windows, quiet rule) are unit-tested without a database. A per-minute `Tick` moves states, decides deliveries and dispatches queued `DeliverJob`s through `ChannelDriver` implementations. HTTP has three entrances with three auth mechanisms: source Bearer tokens (`/api/*`), `X-Remote-User` from the sekai hub (`/`, `/me/*`), and per-delivery action tokens (`/a/*`, `/hooks/*`).
|
||
|
||
**Tech Stack:** PHP 8.4 (Docker) / 8.5 (local), Laravel 13, PHPUnit 12, Postgres 17 (prod), SQLite in-memory (tests), Laravel scheduler + database queue, Telegram Bot API, Docker Compose.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-03-hado-core-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- Core knows no domains: no birthday/passport logic anywhere in `core/`. Only `title`, `subtitle`, `deep_link`, `done_label` in payload.
|
||
- Event identity is `(source_id, source_ref, topic)`, UNIQUE. Recurring events use a period in `topic` (`birthday:2026`); the core never infers recurrence.
|
||
- `due_mode` is an explicit enum `local | fixed`; code branches on it, never on a null column.
|
||
- `fire_on` is always set; no preparation means `fire_on = due day`.
|
||
- `after_due` enum `keep | expire`.
|
||
- States: `scheduled, preparing, today, overdue, done, expired, withdrawn`. Badge = count of `today` + `overdue`.
|
||
- Active hours per user: `[quiet_end, quiet_start)`, defaults `09:00` / `22:00`. Constraint: `quiet_end < quiet_start` (quiet hours cross midnight).
|
||
- Windows: preparing/overdue one per day; today hourly. Fallback margins `HADO_FALLBACK_DAY=30`, `HADO_FALLBACK_HOUR=10` minutes; today fallback only in hours `HADO_FALLBACK_HOURS=9,14,20` or in a window truncated by the due moment.
|
||
- One delivery row per `(event_id, channel_id, window_start)`, inserted as `pending` before the job is dispatched.
|
||
- Presence cache TTL 60 s; presence HTTP timeout `HADO_PRESENCE_TIMEOUT=2` s.
|
||
- Terminal events purged after `HADO_RETENTION_DAYS=90`.
|
||
- All user-facing strings in Russian: «Помню», «Сделано», «Подключено».
|
||
- Tests: PHPUnit, `RefreshDatabase`, time via `CarbonImmutable::setTestNow`, HTTP via `Http::fake()`. Run with `cd core && php artisan test`.
|
||
- Commit after every task with a `feat:`/`test:`/`chore:` prefix. Commit trailer:
|
||
```
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
|
||
```
|
||
- Spec deviation, accepted: DB "enums" are `string` columns backed by PHP enums (portable to SQLite for tests); `deliveries` gains a `meta` json column for channel bookkeeping (Telegram `message_id`). Task 2 patches the spec accordingly.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
core/ Laravel app (created in Task 1)
|
||
app/Enums/DueMode.php local | fixed
|
||
app/Enums/AfterDue.php keep | expire
|
||
app/Enums/EventState.php 7 states + isTerminal/isActive/countsInBadge
|
||
app/Enums/ChannelType.php web | telegram | webhook
|
||
app/Enums/DeliveryResult.php pending | ok | failed
|
||
app/Enums/Presence.php present | absent | unknown
|
||
app/Models/{Source,User,Channel,Event,Delivery}.php
|
||
app/Scheduling/Due.php value: day + moment (local)
|
||
app/Scheduling/DueResolver.php Event+User → Due, todayStart, fireStart
|
||
app/Scheduling/Cascade.php halving points
|
||
app/Scheduling/StateResolver.php dates + now → EventState
|
||
app/Scheduling/Window.php value: start, end, fallbackAllowed, fallbackAt
|
||
app/Scheduling/WindowResolver.php state + now → ?Window
|
||
app/Scheduling/QuietRule.php «Помню» → quiet_until
|
||
app/Ingest/DueAtParser.php "2026-08-14" | "…T14:00" | "…T08:15+02:00" → ParsedDue
|
||
app/Ingest/ParsedDue.php value object
|
||
app/Ingest/EventUpserter.php upsert rules from spec §5
|
||
app/Channels/ChannelDriver.php interface: presence, deliver, onDone
|
||
app/Channels/DeliveryOutcome.php ok | failed(error)
|
||
app/Channels/ChannelRegistry.php ChannelType → driver
|
||
app/Channels/WebChannel.php
|
||
app/Channels/WebhookChannel.php
|
||
app/Channels/TelegramChannel.php
|
||
app/Delivery/Tick.php per-minute planner
|
||
app/Delivery/DeliverJob.php queued delivery with retries
|
||
app/Delivery/EventActions.php ack / done
|
||
app/Http/Middleware/AuthenticateSource.php
|
||
app/Http/Middleware/RemoteUser.php
|
||
app/Http/Requests/UpsertEventsRequest.php
|
||
app/Http/Controllers/Api/EventsController.php
|
||
app/Http/Controllers/Me/{ProfileController,EventsController,ChannelsController}.php
|
||
app/Http/Controllers/ActionController.php
|
||
app/Http/Controllers/TelegramWebhookController.php
|
||
app/Http/Controllers/InboxController.php (design: docs/design/handoff)
|
||
public/inbox.js
|
||
app/Console/Commands/{SourceCreate,TickCommand,PurgeCommand}.php
|
||
config/hado.php
|
||
database/migrations/0001_01_01_000000_create_hado_tables.php
|
||
resources/views/inbox.blade.php
|
||
routes/{api,web,console}.php
|
||
tests/Unit/Scheduling/*, tests/Unit/Ingest/*, tests/Feature/*
|
||
docker-compose.yml, core/Dockerfile, core/Dockerfile.dev, deploy/Caddyfile.snippet, README.md
|
||
docs/design/handoff/ дизайн-хендофф (прототип инбокса, токены, README)
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Scaffold the Laravel app and local toolchain
|
||
|
||
**Files:**
|
||
- Create: `core/` (Laravel 13 skeleton via composer)
|
||
- Modify: `core/phpunit.xml`, `core/.env.example`, `C:\php-8.5.4\php.ini` (enable extensions)
|
||
- Create: `.gitignore` (root)
|
||
|
||
**Interfaces:**
|
||
- Produces: a runnable Laravel app where `php artisan test` passes on SQLite in-memory.
|
||
|
||
- [ ] **Step 1: Enable SQLite/intl/pgsql extensions in local PHP**
|
||
|
||
The dlls exist in `C:\php-8.5.4\ext`, the lines are commented out. Uncomment them:
|
||
|
||
```bash
|
||
sed -i -E 's/^;extension=(pdo_sqlite|sqlite3|intl|pdo_pgsql)$/extension=\1/' /c/php-8.5.4/php.ini
|
||
php -m | grep -E "pdo_sqlite|intl|pdo_pgsql"
|
||
```
|
||
Expected: three lines `intl`, `pdo_pgsql`, `pdo_sqlite`.
|
||
|
||
- [ ] **Step 2: Create the project**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado
|
||
composer create-project laravel/laravel core --no-interaction
|
||
cd core && php artisan --version
|
||
```
|
||
Expected: `Laravel Framework 13.x.y`.
|
||
|
||
- [ ] **Step 3: Configure test environment**
|
||
|
||
In `core/phpunit.xml` inside `<php>`, ensure these exist (Laravel ships most; set the DB and cache ones exactly):
|
||
|
||
```xml
|
||
<env name="APP_ENV" value="testing"/>
|
||
<env name="CACHE_STORE" value="array"/>
|
||
<env name="DB_CONNECTION" value="sqlite"/>
|
||
<env name="DB_DATABASE" value=":memory:"/>
|
||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||
<env name="SESSION_DRIVER" value="array"/>
|
||
<env name="APP_URL" value="http://hado.test"/>
|
||
<env name="HADO_DEFAULT_TZ" value="UTC"/>
|
||
<env name="TELEGRAM_BOT_TOKEN" value="test-token"/>
|
||
<env name="TELEGRAM_BOT_USERNAME" value="hado_test_bot"/>
|
||
<env name="TELEGRAM_WEBHOOK_SECRET" value="test-secret"/>
|
||
```
|
||
|
||
- [ ] **Step 4: Set `.env.example` for Postgres + database queue/cache**
|
||
|
||
Replace the DB/queue/cache section of `core/.env.example` with:
|
||
|
||
```dotenv
|
||
APP_NAME=Hado
|
||
APP_URL=http://localhost:8080
|
||
|
||
DB_CONNECTION=pgsql
|
||
DB_HOST=postgres
|
||
DB_PORT=5432
|
||
DB_DATABASE=hado
|
||
DB_USERNAME=hado
|
||
DB_PASSWORD=hado
|
||
|
||
SESSION_DRIVER=database
|
||
QUEUE_CONNECTION=database
|
||
CACHE_STORE=database
|
||
|
||
HADO_DEFAULT_TZ=America/Montevideo
|
||
HADO_PRESENCE_TIMEOUT=2
|
||
HADO_FALLBACK_DAY=30
|
||
HADO_FALLBACK_HOUR=10
|
||
HADO_FALLBACK_HOURS=9,14,20
|
||
HADO_RETENTION_DAYS=90
|
||
|
||
TELEGRAM_BOT_TOKEN=
|
||
TELEGRAM_BOT_USERNAME=
|
||
TELEGRAM_WEBHOOK_SECRET=
|
||
```
|
||
|
||
Copy it: `cp .env.example .env && php artisan key:generate` (the `.env` is gitignored by Laravel's own `.gitignore`).
|
||
|
||
- [ ] **Step 5: Root `.gitignore` and remove the default example tests**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado
|
||
printf 'core/vendor/\ncore/node_modules/\ncore/.env\ncore/storage/*.key\n' > .gitignore
|
||
rm core/tests/Feature/ExampleTest.php core/tests/Unit/ExampleTest.php
|
||
```
|
||
|
||
- [ ] **Step 6: Run the (empty) suite**
|
||
|
||
```bash
|
||
cd core && php artisan test
|
||
```
|
||
Expected: `No tests executed!` or 0 tests, exit code 0.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "chore: scaffold Laravel 13 core app
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Enums, migration, models
|
||
|
||
**Files:**
|
||
- Create: `core/app/Enums/{DueMode,AfterDue,EventState,ChannelType,DeliveryResult,Presence}.php`
|
||
- Delete: `core/database/migrations/0001_01_01_000000_create_users_table.php`
|
||
- Create: `core/database/migrations/0001_01_01_000000_create_hado_tables.php`
|
||
- Replace: `core/app/Models/User.php`; Create: `core/app/Models/{Source,Channel,Event,Delivery}.php`
|
||
- Create: `core/config/hado.php`
|
||
- Test: `core/tests/Feature/SchemaTest.php`
|
||
- Modify: `docs/superpowers/specs/2026-09-03-hado-core-design.md` (deliveries.meta, string-backed enums)
|
||
|
||
**Interfaces:**
|
||
- Produces: `EventState::isTerminal(): bool`, `isActive(): bool` (preparing/today/overdue), `countsInBadge(): bool`, `static nonTerminalValues(): array<string>`; `User::now(): CarbonImmutable`, `User::channels()`; `Event` casts (`due_date`/`fire_on` → `CarbonImmutable` date, `due_instant`/`quiet_until`/`done_at` → `CarbonImmutable`, `payload` → array, enums); `Event::isTerminal()`; `Delivery` with `meta` array.
|
||
|
||
- [ ] **Step 1: Write the failing schema test**
|
||
|
||
`core/tests/Feature/SchemaTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Channel;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Database\QueryException;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class SchemaTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private function makeEvent(Source $source, User $user, string $topic = 'birthday:2026'): Event
|
||
{
|
||
return Event::create([
|
||
'source_id' => $source->id,
|
||
'user_id' => $user->id,
|
||
'source_ref' => 'person:42',
|
||
'topic' => $topic,
|
||
'due_mode' => DueMode::Local,
|
||
'due_date' => '2026-08-14',
|
||
'due_time' => '24:00:00',
|
||
'fire_on' => '2026-08-14',
|
||
'after_due' => AfterDue::Expire,
|
||
'payload' => ['title' => 'ДР'],
|
||
'state' => EventState::Scheduled,
|
||
'quiet_until' => CarbonImmutable::parse('2000-01-01T00:00:00Z'),
|
||
]);
|
||
}
|
||
|
||
public function test_event_identity_is_unique_per_source_ref_topic(): void
|
||
{
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$this->makeEvent($source, $user);
|
||
|
||
$this->expectException(QueryException::class);
|
||
$this->makeEvent($source, $user);
|
||
}
|
||
|
||
public function test_same_source_ref_with_different_topics_coexist(): void
|
||
{
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$this->makeEvent($source, $user, 'birthday:2026');
|
||
$this->makeEvent($source, $user, 'birthday:2027');
|
||
|
||
$this->assertSame(2, Event::count());
|
||
}
|
||
|
||
public function test_casts_round_trip(): void
|
||
{
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$user = User::create(['login' => 'nikita', 'tz' => 'Asia/Shanghai', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$event = $this->makeEvent($source, $user)->fresh();
|
||
|
||
$this->assertSame(DueMode::Local, $event->due_mode);
|
||
$this->assertSame(EventState::Scheduled, $event->state);
|
||
$this->assertSame('2026-08-14', $event->due_date->format('Y-m-d'));
|
||
$this->assertSame('24:00:00', $event->due_time);
|
||
$this->assertSame(['title' => 'ДР'], $event->payload);
|
||
$this->assertFalse($event->isTerminal());
|
||
$this->assertSame('Asia/Shanghai', $user->now()->timezoneName);
|
||
|
||
Channel::create(['user_id' => $user->id, 'type' => ChannelType::Web, 'config' => [], 'enabled' => true]);
|
||
$this->assertSame(ChannelType::Web, $user->channels()->first()->type);
|
||
}
|
||
|
||
public function test_state_helpers(): void
|
||
{
|
||
$this->assertTrue(EventState::Done->isTerminal());
|
||
$this->assertTrue(EventState::Expired->isTerminal());
|
||
$this->assertTrue(EventState::Withdrawn->isTerminal());
|
||
$this->assertFalse(EventState::Today->isTerminal());
|
||
$this->assertTrue(EventState::Preparing->isActive());
|
||
$this->assertFalse(EventState::Scheduled->isActive());
|
||
$this->assertTrue(EventState::Today->countsInBadge());
|
||
$this->assertTrue(EventState::Overdue->countsInBadge());
|
||
$this->assertFalse(EventState::Preparing->countsInBadge());
|
||
$this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter SchemaTest
|
||
```
|
||
Expected: FAIL, `Class "App\Enums\DueMode" not found`.
|
||
|
||
- [ ] **Step 3: Enums**
|
||
|
||
`core/app/Enums/DueMode.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum DueMode: string
|
||
{
|
||
case Local = 'local';
|
||
case Fixed = 'fixed';
|
||
}
|
||
```
|
||
|
||
`core/app/Enums/AfterDue.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum AfterDue: string
|
||
{
|
||
case Keep = 'keep';
|
||
case Expire = 'expire';
|
||
}
|
||
```
|
||
|
||
`core/app/Enums/EventState.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum EventState: string
|
||
{
|
||
case Scheduled = 'scheduled';
|
||
case Preparing = 'preparing';
|
||
case Today = 'today';
|
||
case Overdue = 'overdue';
|
||
case Done = 'done';
|
||
case Expired = 'expired';
|
||
case Withdrawn = 'withdrawn';
|
||
|
||
public function isTerminal(): bool
|
||
{
|
||
return in_array($this, [self::Done, self::Expired, self::Withdrawn], true);
|
||
}
|
||
|
||
/** Состояния, в которых ядро напоминает. */
|
||
public function isActive(): bool
|
||
{
|
||
return in_array($this, [self::Preparing, self::Today, self::Overdue], true);
|
||
}
|
||
|
||
public function countsInBadge(): bool
|
||
{
|
||
return in_array($this, [self::Today, self::Overdue], true);
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public static function nonTerminalValues(): array
|
||
{
|
||
return array_values(array_map(
|
||
fn (self $s) => $s->value,
|
||
array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()),
|
||
));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Enums/ChannelType.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum ChannelType: string
|
||
{
|
||
case Web = 'web';
|
||
case Telegram = 'telegram';
|
||
case Webhook = 'webhook';
|
||
}
|
||
```
|
||
|
||
`core/app/Enums/DeliveryResult.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum DeliveryResult: string
|
||
{
|
||
case Pending = 'pending';
|
||
case Ok = 'ok';
|
||
case Failed = 'failed';
|
||
}
|
||
```
|
||
|
||
`core/app/Enums/Presence.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum Presence: string
|
||
{
|
||
case Present = 'present';
|
||
case Absent = 'absent';
|
||
case Unknown = 'unknown';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Migration**
|
||
|
||
Delete the default users migration and add ours. Keep the shipped `*_create_cache_table.php` and `*_create_jobs_table.php`.
|
||
|
||
```bash
|
||
rm core/database/migrations/0001_01_01_000000_create_users_table.php
|
||
rm core/database/factories/UserFactory.php
|
||
```
|
||
|
||
And empty the seeder body so nothing references the old auth user: in `core/database/seeders/DatabaseSeeder.php` make `run()` contain only `//`.
|
||
|
||
`core/database/migrations/0001_01_01_000000_create_hado_tables.php`:
|
||
```php
|
||
<?php
|
||
|
||
use Illuminate\Database\Migrations\Migration;
|
||
use Illuminate\Database\Schema\Blueprint;
|
||
use Illuminate\Support\Facades\Schema;
|
||
|
||
return new class extends Migration
|
||
{
|
||
public function up(): void
|
||
{
|
||
Schema::create('sources', function (Blueprint $table) {
|
||
$table->id();
|
||
$table->string('name')->unique();
|
||
$table->string('token_hash', 64)->unique();
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('users', function (Blueprint $table) {
|
||
$table->id();
|
||
$table->string('login')->unique();
|
||
$table->string('tz', 64);
|
||
$table->time('quiet_start')->default('22:00:00');
|
||
$table->time('quiet_end')->default('09:00:00');
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('sessions', function (Blueprint $table) {
|
||
$table->string('id')->primary();
|
||
$table->foreignId('user_id')->nullable()->index();
|
||
$table->string('ip_address', 45)->nullable();
|
||
$table->text('user_agent')->nullable();
|
||
$table->longText('payload');
|
||
$table->integer('last_activity')->index();
|
||
});
|
||
|
||
Schema::create('channels', function (Blueprint $table) {
|
||
$table->id();
|
||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||
$table->string('type', 16);
|
||
$table->json('config');
|
||
$table->boolean('enabled')->default(true);
|
||
$table->timestamps();
|
||
$table->index(['user_id', 'type']);
|
||
});
|
||
|
||
Schema::create('events', function (Blueprint $table) {
|
||
$table->id();
|
||
$table->foreignId('source_id')->constrained()->cascadeOnDelete();
|
||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||
$table->string('source_ref');
|
||
$table->string('topic')->default('');
|
||
$table->string('due_mode', 8);
|
||
$table->date('due_date')->nullable(); // при local
|
||
$table->time('due_time')->nullable(); // при local; 24:00:00 = весь день
|
||
$table->timestampTz('due_instant')->nullable(); // при fixed
|
||
$table->date('fire_on');
|
||
$table->string('after_due', 8);
|
||
$table->json('payload');
|
||
$table->string('state', 16)->index();
|
||
$table->timestampTz('quiet_until');
|
||
$table->timestampTz('done_at')->nullable();
|
||
$table->timestamps();
|
||
$table->unique(['source_id', 'source_ref', 'topic']);
|
||
$table->index(['user_id', 'state']);
|
||
});
|
||
|
||
Schema::create('deliveries', function (Blueprint $table) {
|
||
$table->id();
|
||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||
$table->foreignId('channel_id')->constrained()->cascadeOnDelete();
|
||
$table->timestampTz('window_start');
|
||
$table->string('action_token', 64)->unique();
|
||
$table->string('result', 8)->default('pending');
|
||
$table->timestampTz('sent_at')->nullable();
|
||
$table->text('error')->nullable();
|
||
$table->json('meta');
|
||
$table->timestamps();
|
||
$table->unique(['event_id', 'channel_id', 'window_start']);
|
||
});
|
||
}
|
||
|
||
public function down(): void
|
||
{
|
||
Schema::dropIfExists('deliveries');
|
||
Schema::dropIfExists('events');
|
||
Schema::dropIfExists('channels');
|
||
Schema::dropIfExists('sessions');
|
||
Schema::dropIfExists('users');
|
||
Schema::dropIfExists('sources');
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Models**
|
||
|
||
`core/app/Models/Source.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
||
class Source extends Model
|
||
{
|
||
protected $guarded = [];
|
||
|
||
public function events(): HasMany
|
||
{
|
||
return $this->hasMany(Event::class);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Models/User.php` (replace the shipped file entirely):
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
||
class User extends Model
|
||
{
|
||
protected $guarded = [];
|
||
|
||
public function channels(): HasMany
|
||
{
|
||
return $this->hasMany(Channel::class);
|
||
}
|
||
|
||
public function events(): HasMany
|
||
{
|
||
return $this->hasMany(Event::class);
|
||
}
|
||
|
||
/** «Сейчас» в поясе пользователя. */
|
||
public function now(): CarbonImmutable
|
||
{
|
||
return CarbonImmutable::now($this->tz);
|
||
}
|
||
|
||
/** Начало активных часов в данный календарный день. */
|
||
public function activeStart(CarbonImmutable $day): CarbonImmutable
|
||
{
|
||
return $day->startOfDay()->setTimeFromTimeString($this->quiet_end);
|
||
}
|
||
|
||
/** Конец активных часов (начало тихих) в данный календарный день. */
|
||
public function activeEnd(CarbonImmutable $day): CarbonImmutable
|
||
{
|
||
return $day->startOfDay()->setTimeFromTimeString($this->quiet_start);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Models/Channel.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\ChannelType;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
class Channel extends Model
|
||
{
|
||
protected $guarded = [];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'type' => ChannelType::class,
|
||
'config' => 'array',
|
||
'enabled' => 'boolean',
|
||
];
|
||
}
|
||
|
||
public function user(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Models/Event.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
||
class Event extends Model
|
||
{
|
||
protected $guarded = [];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'due_mode' => DueMode::class,
|
||
'after_due' => AfterDue::class,
|
||
'state' => EventState::class,
|
||
'due_date' => 'immutable_date',
|
||
'fire_on' => 'immutable_date',
|
||
'due_instant' => 'immutable_datetime',
|
||
'quiet_until' => 'immutable_datetime',
|
||
'done_at' => 'immutable_datetime',
|
||
'payload' => 'array',
|
||
];
|
||
}
|
||
|
||
public function source(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Source::class);
|
||
}
|
||
|
||
public function user(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
|
||
public function deliveries(): HasMany
|
||
{
|
||
return $this->hasMany(Delivery::class);
|
||
}
|
||
|
||
public function isTerminal(): bool
|
||
{
|
||
return $this->state->isTerminal();
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Models/Delivery.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\DeliveryResult;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
class Delivery extends Model
|
||
{
|
||
protected $guarded = [];
|
||
|
||
protected $attributes = ['meta' => '{}'];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'result' => DeliveryResult::class,
|
||
'window_start' => 'immutable_datetime',
|
||
'sent_at' => 'immutable_datetime',
|
||
'meta' => 'array',
|
||
];
|
||
}
|
||
|
||
public function event(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Event::class);
|
||
}
|
||
|
||
public function channel(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Channel::class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Config**
|
||
|
||
`core/config/hado.php`:
|
||
```php
|
||
<?php
|
||
|
||
return [
|
||
'default_tz' => env('HADO_DEFAULT_TZ', 'UTC'),
|
||
'presence_timeout' => (int) env('HADO_PRESENCE_TIMEOUT', 2),
|
||
'presence_ttl' => 60,
|
||
'fallback_day_minutes' => (int) env('HADO_FALLBACK_DAY', 30),
|
||
'fallback_hour_minutes' => (int) env('HADO_FALLBACK_HOUR', 10),
|
||
'fallback_hours' => array_map('intval', explode(',', env('HADO_FALLBACK_HOURS', '9,14,20'))),
|
||
'retention_days' => (int) env('HADO_RETENTION_DAYS', 90),
|
||
'telegram' => [
|
||
'token' => env('TELEGRAM_BOT_TOKEN', ''),
|
||
'username' => env('TELEGRAM_BOT_USERNAME', ''),
|
||
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''),
|
||
'seen_ttl' => 600,
|
||
],
|
||
'web_heartbeat_ttl' => 90,
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter SchemaTest
|
||
```
|
||
Expected: 4 tests PASS.
|
||
|
||
- [ ] **Step 8: Patch the spec for the two accepted deviations**
|
||
|
||
In `docs/superpowers/specs/2026-09-03-hado-core-design.md`, §4 `deliveries` block: add a line ` meta jsonb -- служебное канала, напр. telegram message_id` after `sent_at`. After the data-model code block add the sentence: «Все `enum(...)` в схеме — строковые колонки, значения фиксирует PHP-enum; так тесты гоняются на SQLite.»
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: enums, schema and models for hado core
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: DueResolver and Cascade (pure scheduling math)
|
||
|
||
**Files:**
|
||
- Create: `core/app/Scheduling/Due.php`, `core/app/Scheduling/DueResolver.php`, `core/app/Scheduling/Cascade.php`
|
||
- Test: `core/tests/Unit/Scheduling/DueResolverTest.php`, `core/tests/Unit/Scheduling/CascadeTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Event`, `User` models (in-memory, no DB).
|
||
- Produces:
|
||
- `final class Due { CarbonImmutable $day; CarbonImmutable $moment; }` both in the user's tz; `day` is local midnight of the due day; `moment` is the due moment (for a whole-day event: next midnight).
|
||
- `DueResolver::resolve(Event, User): Due`
|
||
- `DueResolver::fireStart(Event, User): CarbonImmutable` local midnight of `fire_on`
|
||
- `DueResolver::todayStart(Due, User): CarbonImmutable` local midnight of due day, or `quiet_start − 2h` the day before when `moment <= activeStart(day)`
|
||
- `Cascade::points(CarbonImmutable $fireStart, CarbonImmutable $dueDay): list<CarbonImmutable>` halving points, ascending
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`core/tests/Unit/Scheduling/CascadeTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Scheduling;
|
||
|
||
use App\Scheduling\Cascade;
|
||
use Carbon\CarbonImmutable;
|
||
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||
|
||
class CascadeTest extends TestCase
|
||
{
|
||
private function daysBefore(array $points, CarbonImmutable $due): array
|
||
{
|
||
return array_map(fn (CarbonImmutable $p) => (int) round($p->diffInDays($due)), $points);
|
||
}
|
||
|
||
public function test_no_preparation_gives_no_points(): void
|
||
{
|
||
$due = CarbonImmutable::parse('2026-08-14', 'UTC');
|
||
$this->assertSame([], Cascade::points($due, $due));
|
||
}
|
||
|
||
public function test_halving_points(): void
|
||
{
|
||
$due = CarbonImmutable::parse('2026-09-30', 'UTC');
|
||
|
||
$this->assertSame([1], $this->daysBefore(Cascade::points($due->subDay(), $due), $due));
|
||
$this->assertSame([7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(7), $due), $due));
|
||
$this->assertSame([30, 15, 7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(30), $due), $due));
|
||
$this->assertSame([60, 30, 15, 7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(60), $due), $due));
|
||
$this->assertSame([180, 90, 45, 22, 11, 5, 2, 1], $this->daysBefore(Cascade::points($due->subDays(180), $due), $due));
|
||
}
|
||
|
||
public function test_points_are_local_midnights_ascending(): void
|
||
{
|
||
$due = CarbonImmutable::parse('2026-09-30', 'Asia/Shanghai');
|
||
$points = Cascade::points($due->subDays(7), $due);
|
||
|
||
$this->assertSame('2026-09-23 00:00 Asia/Shanghai', $points[0]->format('Y-m-d H:i e'));
|
||
$this->assertSame('2026-09-29 00:00 Asia/Shanghai', $points[2]->format('Y-m-d H:i e'));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Unit/Scheduling/DueResolverTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Scheduling;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use App\Scheduling\DueResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||
|
||
class DueResolverTest extends TestCase
|
||
{
|
||
private function user(string $tz = 'America/Montevideo'): User
|
||
{
|
||
return new User(['login' => 'n', 'tz' => $tz, 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
}
|
||
|
||
private function local(string $date, string $time = '24:00:00', ?string $fireOn = null): Event
|
||
{
|
||
return new Event([
|
||
'due_mode' => DueMode::Local, 'due_date' => $date, 'due_time' => $time,
|
||
'fire_on' => $fireOn ?? $date, 'after_due' => AfterDue::Expire,
|
||
'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
|
||
]);
|
||
}
|
||
|
||
private function fixed(string $instant, string $fireOn): Event
|
||
{
|
||
return new Event([
|
||
'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse($instant),
|
||
'fire_on' => $fireOn, 'after_due' => AfterDue::Expire,
|
||
'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
|
||
]);
|
||
}
|
||
|
||
public function test_whole_day_local_event(): void
|
||
{
|
||
$due = (new DueResolver)->resolve($this->local('2026-08-14'), $this->user());
|
||
|
||
$this->assertSame('2026-08-14 00:00 America/Montevideo', $due->day->format('Y-m-d H:i e'));
|
||
$this->assertSame('2026-08-15 00:00 America/Montevideo', $due->moment->format('Y-m-d H:i e'));
|
||
}
|
||
|
||
public function test_timed_local_event(): void
|
||
{
|
||
$due = (new DueResolver)->resolve($this->local('2026-09-10', '14:00:00'), $this->user());
|
||
|
||
$this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i'));
|
||
$this->assertSame('2026-09-10 14:00', $due->moment->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_local_event_follows_user_timezone(): void
|
||
{
|
||
$event = $this->local('2026-09-10', '14:00:00');
|
||
|
||
$uy = (new DueResolver)->resolve($event, $this->user('America/Montevideo'));
|
||
$cn = (new DueResolver)->resolve($event, $this->user('Asia/Shanghai'));
|
||
|
||
$this->assertSame('2026-09-10 14:00', $uy->moment->format('Y-m-d H:i'));
|
||
$this->assertSame('2026-09-10 14:00', $cn->moment->format('Y-m-d H:i'));
|
||
$this->assertNotEquals($uy->moment->utc()->toIso8601String(), $cn->moment->utc()->toIso8601String());
|
||
}
|
||
|
||
public function test_fixed_event_is_converted_into_user_timezone(): void
|
||
{
|
||
// Вылет из Мадрида 08:15+02:00 = 06:15Z = 03:15 в Монтевидео (-03:00)
|
||
$due = (new DueResolver)->resolve($this->fixed('2026-09-10T08:15:00+02:00', '2026-09-03'), $this->user());
|
||
|
||
$this->assertSame('2026-09-10 03:15 America/Montevideo', $due->moment->format('Y-m-d H:i e'));
|
||
$this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_fire_start_is_local_midnight(): void
|
||
{
|
||
$start = (new DueResolver)->fireStart($this->local('2026-09-30', '24:00:00', '2026-08-31'), $this->user());
|
||
$this->assertSame('2026-08-31 00:00 America/Montevideo', $start->format('Y-m-d H:i e'));
|
||
}
|
||
|
||
public function test_today_starts_at_midnight_for_normal_moment(): void
|
||
{
|
||
$r = new DueResolver;
|
||
$due = $r->resolve($this->local('2026-09-10', '14:00:00'), $this->user());
|
||
$this->assertSame('2026-09-10 00:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_today_starts_the_evening_before_for_early_moment(): void
|
||
{
|
||
$r = new DueResolver;
|
||
$due = $r->resolve($this->local('2026-09-10', '07:00:00'), $this->user());
|
||
// quiet_start 22:00 − 2h = 20:00 накануне
|
||
$this->assertSame('2026-09-09 20:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i'));
|
||
|
||
$exactlyAtActiveStart = $r->resolve($this->local('2026-09-10', '09:00:00'), $this->user());
|
||
$this->assertSame('2026-09-09 20:00', $r->todayStart($exactlyAtActiveStart, $this->user())->format('Y-m-d H:i'));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "CascadeTest|DueResolverTest"
|
||
```
|
||
Expected: FAIL, class not found.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Scheduling/Due.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
|
||
/** Срок события, приведённый к поясу пользователя. */
|
||
final class Due
|
||
{
|
||
public function __construct(
|
||
/** Локальная полночь дня срока. */
|
||
public readonly CarbonImmutable $day,
|
||
/** Момент срока. Для события «весь день» — следующая полночь. */
|
||
public readonly CarbonImmutable $moment,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
`core/app/Scheduling/DueResolver.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use App\Enums\DueMode;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
|
||
final class DueResolver
|
||
{
|
||
public const WHOLE_DAY = '24:00:00';
|
||
|
||
public function resolve(Event $event, User $user): Due
|
||
{
|
||
return match ($event->due_mode) {
|
||
DueMode::Fixed => $this->fixed($event, $user),
|
||
DueMode::Local => $this->local($event, $user),
|
||
};
|
||
}
|
||
|
||
public function fireStart(Event $event, User $user): CarbonImmutable
|
||
{
|
||
return self::localMidnight($event->fire_on->format('Y-m-d'), $user->tz);
|
||
}
|
||
|
||
/**
|
||
* Старт дня срока. Обычно локальная полночь дня срока. Если момент срока не
|
||
* позже начала активных часов, первое окно дня уже опоздало бы — тогда старт
|
||
* за два часа до тихих часов накануне (два последних часовых окна вечера).
|
||
*/
|
||
public function todayStart(Due $due, User $user): CarbonImmutable
|
||
{
|
||
if ($due->moment->lte($user->activeStart($due->day))) {
|
||
return $user->activeEnd($due->day->subDay())->subHours(2);
|
||
}
|
||
|
||
return $due->day;
|
||
}
|
||
|
||
public static function localMidnight(string $ymd, string $tz): CarbonImmutable
|
||
{
|
||
return CarbonImmutable::createFromFormat('Y-m-d', $ymd, $tz)->startOfDay();
|
||
}
|
||
|
||
private function local(Event $event, User $user): Due
|
||
{
|
||
$day = self::localMidnight($event->due_date->format('Y-m-d'), $user->tz);
|
||
$moment = $event->due_time === self::WHOLE_DAY
|
||
? $day->addDay()
|
||
: $day->setTimeFromTimeString($event->due_time);
|
||
|
||
return new Due($day, $moment);
|
||
}
|
||
|
||
private function fixed(Event $event, User $user): Due
|
||
{
|
||
$moment = $event->due_instant->setTimezone($user->tz);
|
||
|
||
return new Due($moment->startOfDay(), $moment);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Scheduling/Cascade.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
|
||
final class Cascade
|
||
{
|
||
/**
|
||
* Точки каскада между стартом и днём срока: старт, затем каждый раз, когда
|
||
* до срока остаётся вдвое меньше дней, до одного дня. Обе даты — локальные
|
||
* полуночи в одном поясе. Возвращает локальные полуночи по возрастанию.
|
||
*
|
||
* @return list<CarbonImmutable>
|
||
*/
|
||
public static function points(CarbonImmutable $fireStart, CarbonImmutable $dueDay): array
|
||
{
|
||
$d = (int) round($fireStart->diffInDays($dueDay));
|
||
$points = [];
|
||
while ($d >= 1) {
|
||
$points[] = $dueDay->subDays($d);
|
||
$d = intdiv($d, 2);
|
||
}
|
||
|
||
return $points;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "CascadeTest|DueResolverTest"
|
||
```
|
||
Expected: 10 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: due resolution and cascade math
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: StateResolver, WindowResolver, QuietRule
|
||
|
||
**Files:**
|
||
- Create: `core/app/Scheduling/StateResolver.php`, `core/app/Scheduling/Window.php`, `core/app/Scheduling/WindowResolver.php`, `core/app/Scheduling/QuietRule.php`
|
||
- Modify: `core/app/Providers/AppServiceProvider.php` (bind `WindowResolver` from config)
|
||
- Test: `core/tests/Unit/Scheduling/StateResolverTest.php`, `WindowResolverTest.php`, `QuietRuleTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DueResolver`, `Cascade`, `Due`, `User::activeStart/activeEnd`.
|
||
- Produces:
|
||
- `StateResolver::resolve(Event, User, CarbonImmutable $now): EventState` — non-terminal state implied by dates. Never returns `Done`/`Withdrawn`.
|
||
- `final class Window { CarbonImmutable $start; CarbonImmutable $end; bool $fallbackAllowed; CarbonImmutable $fallbackAt; }`
|
||
- `WindowResolver::__construct(int $dayMarginMinutes, int $hourMarginMinutes, array $fallbackHours)`
|
||
- `WindowResolver::current(EventState, User, Due, CarbonImmutable $now): ?Window`
|
||
- `QuietRule::afterAck(Event, User, EventState, Due, CarbonImmutable $now): CarbonImmutable`
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`core/tests/Unit/Scheduling/StateResolverTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Scheduling;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use App\Scheduling\DueResolver;
|
||
use App\Scheduling\StateResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||
|
||
class StateResolverTest extends TestCase
|
||
{
|
||
private User $user;
|
||
private StateResolver $resolver;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->user = new User(['login' => 'n', 'tz' => 'America/Montevideo', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$this->resolver = new StateResolver(new DueResolver);
|
||
}
|
||
|
||
private function event(string $due, string $fireOn, AfterDue $afterDue = AfterDue::Keep, string $time = '24:00:00'): Event
|
||
{
|
||
return new Event([
|
||
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn,
|
||
'after_due' => $afterDue, 'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
|
||
]);
|
||
}
|
||
|
||
private function at(string $local): CarbonImmutable
|
||
{
|
||
return CarbonImmutable::parse($local, 'America/Montevideo');
|
||
}
|
||
|
||
public function test_deadline_walks_through_states(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-08-31', AfterDue::Keep);
|
||
|
||
$this->assertSame(EventState::Scheduled, $this->resolver->resolve($e, $this->user, $this->at('2026-08-30 23:59')));
|
||
$this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-08-31 00:00')));
|
||
$this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-09-29 23:59')));
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-30 00:00')));
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-30 23:59')));
|
||
$this->assertSame(EventState::Overdue, $this->resolver->resolve($e, $this->user, $this->at('2026-10-01 00:00')));
|
||
}
|
||
|
||
public function test_day_of_never_prepares_and_expires(): void
|
||
{
|
||
$e = $this->event('2026-08-14', '2026-08-14', AfterDue::Expire);
|
||
|
||
$this->assertSame(EventState::Scheduled, $this->resolver->resolve($e, $this->user, $this->at('2026-08-13 23:59')));
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-08-14 00:00')));
|
||
$this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-08-15 00:00')));
|
||
}
|
||
|
||
public function test_timed_moment_ends_today_mid_day(): void
|
||
{
|
||
$e = $this->event('2026-09-10', '2026-09-03', AfterDue::Expire, '14:00:00');
|
||
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 13:59')));
|
||
$this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 14:00')));
|
||
}
|
||
|
||
public function test_early_moment_starts_today_the_evening_before(): void
|
||
{
|
||
$e = $this->event('2026-09-10', '2026-09-03', AfterDue::Expire, '07:00:00');
|
||
|
||
$this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 19:59')));
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 20:00')));
|
||
$this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 07:00')));
|
||
}
|
||
|
||
public function test_fixed_flight_uses_user_timezone(): void
|
||
{
|
||
$e = new Event([
|
||
'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse('2026-09-10T08:15:00+02:00'),
|
||
'fire_on' => '2026-09-03', 'after_due' => AfterDue::Expire, 'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
|
||
]);
|
||
// 03:15 по Монтевидео → раньше активных часов → today с 20:00 накануне
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 20:00')));
|
||
$this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 03:14')));
|
||
$this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 03:15')));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Unit/Scheduling/WindowResolverTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Scheduling;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Models\User;
|
||
use App\Scheduling\Due;
|
||
use App\Scheduling\WindowResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||
|
||
class WindowResolverTest extends TestCase
|
||
{
|
||
private User $user;
|
||
private WindowResolver $windows;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$this->windows = new WindowResolver(30, 10, [9, 14, 20]);
|
||
}
|
||
|
||
private function at(string $s): CarbonImmutable
|
||
{
|
||
return CarbonImmutable::parse($s, 'UTC');
|
||
}
|
||
|
||
private function wholeDay(string $ymd): Due
|
||
{
|
||
$day = $this->at($ymd);
|
||
return new Due($day, $day->addDay());
|
||
}
|
||
|
||
public function test_outside_active_hours_there_is_no_window(): void
|
||
{
|
||
$due = $this->wholeDay('2026-09-30');
|
||
$this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 08:59')));
|
||
$this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 22:00')));
|
||
$this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 23:00')));
|
||
}
|
||
|
||
public function test_preparing_and_overdue_have_one_daily_window(): void
|
||
{
|
||
$due = $this->wholeDay('2026-09-30');
|
||
foreach ([EventState::Preparing, EventState::Overdue] as $state) {
|
||
$w = $this->windows->current($state, $this->user, $due, $this->at('2026-09-20 12:34'));
|
||
$this->assertSame('2026-09-20 09:00', $w->start->format('Y-m-d H:i'));
|
||
$this->assertSame('2026-09-20 22:00', $w->end->format('Y-m-d H:i'));
|
||
$this->assertTrue($w->fallbackAllowed);
|
||
$this->assertSame('2026-09-20 21:30', $w->fallbackAt->format('Y-m-d H:i'));
|
||
}
|
||
}
|
||
|
||
public function test_today_has_hourly_windows_with_fallback_only_in_listed_hours(): void
|
||
{
|
||
$due = $this->wholeDay('2026-09-30');
|
||
|
||
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:20'));
|
||
$this->assertSame('2026-09-30 09:00', $w->start->format('Y-m-d H:i'));
|
||
$this->assertSame('2026-09-30 10:00', $w->end->format('Y-m-d H:i'));
|
||
$this->assertTrue($w->fallbackAllowed);
|
||
$this->assertSame('2026-09-30 09:50', $w->fallbackAt->format('Y-m-d H:i'));
|
||
|
||
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:05'));
|
||
$this->assertSame('2026-09-30 11:00', $w->start->format('Y-m-d H:i'));
|
||
$this->assertFalse($w->fallbackAllowed);
|
||
|
||
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 21:59'));
|
||
$this->assertSame('2026-09-30 22:00', $w->end->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_today_window_is_truncated_by_due_moment_and_becomes_fallback(): void
|
||
{
|
||
$day = $this->at('2026-09-30');
|
||
|
||
$due = new Due($day, $day->setTime(9, 30));
|
||
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:05'));
|
||
$this->assertSame('2026-09-30 09:30', $w->end->format('Y-m-d H:i'));
|
||
$this->assertSame('2026-09-30 09:20', $w->fallbackAt->format('Y-m-d H:i'));
|
||
|
||
$due = new Due($day, $day->setTime(11, 15));
|
||
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:02'));
|
||
$this->assertSame('2026-09-30 11:15', $w->end->format('Y-m-d H:i'));
|
||
$this->assertTrue($w->fallbackAllowed, 'обрезанное окно всегда fallback-окно');
|
||
$this->assertSame('2026-09-30 11:05', $w->fallbackAt->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_no_window_once_due_moment_passed(): void
|
||
{
|
||
$day = $this->at('2026-09-30');
|
||
$due = new Due($day, $day->setTime(11, 15));
|
||
$this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:20')));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Unit/Scheduling/QuietRuleTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Scheduling;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use App\Scheduling\DueResolver;
|
||
use App\Scheduling\QuietRule;
|
||
use Carbon\CarbonImmutable;
|
||
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||
|
||
class QuietRuleTest extends TestCase
|
||
{
|
||
private User $user;
|
||
private DueResolver $due;
|
||
private QuietRule $rule;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$this->due = new DueResolver;
|
||
$this->rule = new QuietRule($this->due);
|
||
}
|
||
|
||
private function event(string $due, string $fireOn, string $time = '24:00:00'): Event
|
||
{
|
||
return new Event([
|
||
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn,
|
||
'after_due' => AfterDue::Keep, 'state' => EventState::Preparing, 'payload' => ['title' => 't'],
|
||
]);
|
||
}
|
||
|
||
private function at(string $s): CarbonImmutable
|
||
{
|
||
return CarbonImmutable::parse($s, 'UTC');
|
||
}
|
||
|
||
public function test_preparing_ack_is_quiet_until_next_cascade_point(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-08-31'); // точки: 30,15,7,3,1 → 31.08, 15.09, 23.09, 27.09, 29.09
|
||
$due = $this->due->resolve($e, $this->user);
|
||
|
||
$q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-02 10:00'));
|
||
$this->assertSame('2026-09-15 00:00', $q->format('Y-m-d H:i'));
|
||
|
||
$q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-15 10:00'));
|
||
$this->assertSame('2026-09-23 00:00', $q->format('Y-m-d H:i'), 'на самой точке — до следующей');
|
||
|
||
$q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-29 10:00'));
|
||
$this->assertSame('2026-09-30 00:00', $q->format('Y-m-d H:i'), 'точек больше нет — до старта дня срока');
|
||
}
|
||
|
||
public function test_today_ack_skips_the_next_hour(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-09-30');
|
||
$due = $this->due->resolve($e, $this->user);
|
||
|
||
$q = $this->rule->afterAck($e, $this->user, EventState::Today, $due, $this->at('2026-09-30 11:17'));
|
||
$this->assertSame('2026-09-30 13:00', $q->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_overdue_ack_skips_tomorrow(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-09-30');
|
||
$due = $this->due->resolve($e, $this->user);
|
||
|
||
$q = $this->rule->afterAck($e, $this->user, EventState::Overdue, $due, $this->at('2026-10-05 15:00'));
|
||
$this->assertSame('2026-10-07 00:00', $q->format('Y-m-d H:i'));
|
||
}
|
||
|
||
public function test_scheduled_ack_changes_nothing(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-09-30');
|
||
$due = $this->due->resolve($e, $this->user);
|
||
$now = $this->at('2026-09-01 15:00');
|
||
|
||
$this->assertTrue($this->rule->afterAck($e, $this->user, EventState::Scheduled, $due, $now)->eq($now));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "StateResolverTest|WindowResolverTest|QuietRuleTest"
|
||
```
|
||
Expected: FAIL, classes not found.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Scheduling/StateResolver.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
|
||
/**
|
||
* Состояние, которое следует из дат события и «сейчас». Используется и кроном
|
||
* (переходы), и upsert-ом (пересчёт). Терминальные Done/Withdrawn ставятся
|
||
* только действиями, сюда не входят.
|
||
*/
|
||
final class StateResolver
|
||
{
|
||
public function __construct(private readonly DueResolver $due) {}
|
||
|
||
public function resolve(Event $event, User $user, CarbonImmutable $now): EventState
|
||
{
|
||
$due = $this->due->resolve($event, $user);
|
||
|
||
if ($now->gte($due->moment)) {
|
||
return $event->after_due === AfterDue::Keep ? EventState::Overdue : EventState::Expired;
|
||
}
|
||
|
||
if ($now->gte($this->due->todayStart($due, $user))) {
|
||
return EventState::Today;
|
||
}
|
||
|
||
$fireStart = $this->due->fireStart($event, $user);
|
||
if ($fireStart->lt($due->day) && $now->gte($fireStart)) {
|
||
return EventState::Preparing;
|
||
}
|
||
|
||
return EventState::Scheduled;
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Scheduling/Window.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
|
||
/** Окно доставки: внутри него ядро ищет момент, когда пользователь доступен. */
|
||
final class Window
|
||
{
|
||
public function __construct(
|
||
public readonly CarbonImmutable $start,
|
||
public readonly CarbonImmutable $end,
|
||
/** Разрешена ли доставка «во все каналы наугад», если никто не present. */
|
||
public readonly bool $fallbackAllowed,
|
||
/** С какого момента fallback срабатывает (конец окна минус запас). */
|
||
public readonly CarbonImmutable $fallbackAt,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
`core/app/Scheduling/WindowResolver.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
|
||
final class WindowResolver
|
||
{
|
||
/** @param list<int> $fallbackHours */
|
||
public function __construct(
|
||
private readonly int $dayMarginMinutes,
|
||
private readonly int $hourMarginMinutes,
|
||
private readonly array $fallbackHours,
|
||
) {}
|
||
|
||
/** Окно, открытое в момент $now, или null (тихие часы / срок прошёл / состояние не напоминает). */
|
||
public function current(EventState $state, User $user, Due $due, CarbonImmutable $now): ?Window
|
||
{
|
||
$activeStart = $user->activeStart($now);
|
||
$activeEnd = $user->activeEnd($now);
|
||
if ($now->lt($activeStart) || $now->gte($activeEnd)) {
|
||
return null;
|
||
}
|
||
|
||
return match ($state) {
|
||
EventState::Preparing, EventState::Overdue => new Window(
|
||
$activeStart, $activeEnd, true, $activeEnd->subMinutes($this->dayMarginMinutes),
|
||
),
|
||
EventState::Today => $this->hourly($due, $now, $activeEnd),
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
private function hourly(Due $due, CarbonImmutable $now, CarbonImmutable $activeEnd): ?Window
|
||
{
|
||
$start = $now->startOfHour();
|
||
$end = $start->addHour()->min($activeEnd);
|
||
|
||
$truncated = false;
|
||
if ($due->moment->gt($start) && $due->moment->lt($end)) {
|
||
$end = $due->moment;
|
||
$truncated = true;
|
||
}
|
||
|
||
if ($now->gte($end)) {
|
||
return null;
|
||
}
|
||
|
||
$fallbackAllowed = $truncated || in_array((int) $start->format('G'), $this->fallbackHours, true);
|
||
|
||
return new Window($start, $end, $fallbackAllowed, $end->subMinutes($this->hourMarginMinutes));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Scheduling/QuietRule.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Scheduling;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
|
||
/** «Помню»: до какого момента молчать, по текущему состоянию. */
|
||
final class QuietRule
|
||
{
|
||
public function __construct(private readonly DueResolver $due) {}
|
||
|
||
public function afterAck(Event $event, User $user, EventState $state, Due $due, CarbonImmutable $now): CarbonImmutable
|
||
{
|
||
return match ($state) {
|
||
EventState::Preparing => $this->nextCascadePoint($event, $user, $due, $now),
|
||
EventState::Today => $now->startOfHour()->addHours(2),
|
||
EventState::Overdue => $now->startOfDay()->addDays(2),
|
||
default => $now,
|
||
};
|
||
}
|
||
|
||
private function nextCascadePoint(Event $event, User $user, Due $due, CarbonImmutable $now): CarbonImmutable
|
||
{
|
||
$today = $now->startOfDay();
|
||
foreach (Cascade::points($this->due->fireStart($event, $user), $due->day) as $point) {
|
||
if ($point->gt($today)) {
|
||
return $point;
|
||
}
|
||
}
|
||
|
||
return $this->due->todayStart($due, $user);
|
||
}
|
||
}
|
||
```
|
||
|
||
Bind the resolver from config in `core/app/Providers/AppServiceProvider.php` `register()`:
|
||
```php
|
||
$this->app->singleton(\App\Scheduling\WindowResolver::class, fn () => new \App\Scheduling\WindowResolver(
|
||
config('hado.fallback_day_minutes'),
|
||
config('hado.fallback_hour_minutes'),
|
||
config('hado.fallback_hours'),
|
||
));
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "StateResolverTest|WindowResolverTest|QuietRuleTest"
|
||
```
|
||
Expected: 14 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: state, window and quiet rules
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: DueAtParser and EventUpserter
|
||
|
||
**Files:**
|
||
- Create: `core/app/Ingest/ParsedDue.php`, `core/app/Ingest/DueAtParser.php`, `core/app/Ingest/EventUpserter.php`
|
||
- Test: `core/tests/Unit/Ingest/DueAtParserTest.php`, `core/tests/Feature/EventUpserterTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `StateResolver`, models, config `hado.default_tz`.
|
||
- Produces:
|
||
- `final class ParsedDue { DueMode $mode; ?string $date; ?string $time; ?CarbonImmutable $instant; }` with `static local(string $date, string $time)`, `static fixed(CarbonImmutable)`, `dayFor(string $tz): string` (Y-m-d), `equals(Event): bool`.
|
||
- `DueAtParser::parse(string): ParsedDue` throws `InvalidArgumentException` on bad input.
|
||
- `EventUpserter::upsert(Source $source, array $items): list<array{source_ref,topic,id,state}>` — `$items` are already-validated request rows.
|
||
- `EventUpserter::ensureUser(string $login): User` — creates user + web channel.
|
||
- `EventUpserter::withdraw(Source, string $sourceRef, string $topic): bool`
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`core/tests/Unit/Ingest/DueAtParserTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Ingest;
|
||
|
||
use App\Enums\DueMode;
|
||
use App\Ingest\DueAtParser;
|
||
use InvalidArgumentException;
|
||
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||
|
||
class DueAtParserTest extends TestCase
|
||
{
|
||
public function test_date_only_is_whole_day_local(): void
|
||
{
|
||
$p = DueAtParser::parse('2026-08-14');
|
||
$this->assertSame(DueMode::Local, $p->mode);
|
||
$this->assertSame('2026-08-14', $p->date);
|
||
$this->assertSame('24:00:00', $p->time);
|
||
$this->assertNull($p->instant);
|
||
$this->assertSame('2026-08-14', $p->dayFor('Asia/Shanghai'));
|
||
}
|
||
|
||
public function test_datetime_without_offset_is_local(): void
|
||
{
|
||
$p = DueAtParser::parse('2026-09-10T14:00');
|
||
$this->assertSame(DueMode::Local, $p->mode);
|
||
$this->assertSame('2026-09-10', $p->date);
|
||
$this->assertSame('14:00:00', $p->time);
|
||
|
||
$this->assertSame('14:05:30', DueAtParser::parse('2026-09-10T14:05:30')->time);
|
||
}
|
||
|
||
public function test_datetime_with_offset_is_fixed(): void
|
||
{
|
||
$p = DueAtParser::parse('2026-09-10T08:15+02:00');
|
||
$this->assertSame(DueMode::Fixed, $p->mode);
|
||
$this->assertNull($p->date);
|
||
$this->assertSame('2026-09-10T06:15:00+00:00', $p->instant->utc()->toIso8601String());
|
||
$this->assertSame('2026-09-10', $p->dayFor('America/Montevideo'));
|
||
$this->assertSame('2026-09-10', $p->dayFor('Asia/Shanghai'));
|
||
|
||
$this->assertSame(DueMode::Fixed, DueAtParser::parse('2026-09-10T06:15:00Z')->mode);
|
||
}
|
||
|
||
public function test_garbage_is_rejected(): void
|
||
{
|
||
foreach (['', 'tomorrow', '2026-13-01', '14:00', '2026-09-10 14:00', '2026-09-10T25:00'] as $bad) {
|
||
try {
|
||
DueAtParser::parse($bad);
|
||
$this->fail("accepted: $bad");
|
||
} catch (InvalidArgumentException) {
|
||
$this->addToAssertionCount(1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Feature/EventUpserterTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\EventState;
|
||
use App\Ingest\EventUpserter;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class EventUpserterTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private Source $source;
|
||
private EventUpserter $upserter;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
config(['hado.default_tz' => 'America/Montevideo']);
|
||
CarbonImmutable::setTestNow('2026-09-01T12:00:00-03:00');
|
||
$this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$this->upserter = app(EventUpserter::class);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
private function item(array $over = []): array
|
||
{
|
||
return array_replace([
|
||
'user' => 'nikita', 'source_ref' => 'document:918', 'topic' => 'expiry',
|
||
'due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep',
|
||
'payload' => ['title' => 'Седула протухает 30 сен'],
|
||
], $over);
|
||
}
|
||
|
||
public function test_creates_event_user_and_web_channel_with_computed_state(): void
|
||
{
|
||
$result = $this->upserter->upsert($this->source, [$this->item()]);
|
||
|
||
$this->assertSame([['source_ref' => 'document:918', 'topic' => 'expiry', 'id' => 1, 'state' => 'preparing']], $result);
|
||
$user = User::where('login', 'nikita')->firstOrFail();
|
||
$this->assertSame('America/Montevideo', $user->tz);
|
||
$this->assertSame(ChannelType::Web, $user->channels()->sole()->type);
|
||
$event = Event::sole();
|
||
$this->assertSame(EventState::Preparing, $event->state);
|
||
$this->assertTrue($event->quiet_until->lt(CarbonImmutable::now()));
|
||
}
|
||
|
||
public function test_same_due_updates_fields_and_keeps_terminal_state(): void
|
||
{
|
||
$this->upserter->upsert($this->source, [$this->item()]);
|
||
Event::sole()->update(['state' => EventState::Done, 'done_at' => now()]);
|
||
|
||
$this->upserter->upsert($this->source, [$this->item(['payload' => ['title' => 'Новый заголовок'], 'fire_at' => '2026-09-15'])]);
|
||
|
||
$event = Event::sole();
|
||
$this->assertSame(EventState::Done, $event->state);
|
||
$this->assertSame('Новый заголовок', $event->payload['title']);
|
||
$this->assertSame('2026-09-15', $event->fire_on->format('Y-m-d'));
|
||
}
|
||
|
||
public function test_same_due_recomputes_non_terminal_state(): void
|
||
{
|
||
$this->upserter->upsert($this->source, [$this->item()]);
|
||
$this->assertSame(EventState::Preparing, Event::sole()->state);
|
||
|
||
$this->upserter->upsert($this->source, [$this->item(['fire_at' => '2026-09-20'])]);
|
||
$this->assertSame(EventState::Scheduled, Event::sole()->state, 'старт отодвинули в будущее');
|
||
}
|
||
|
||
public function test_new_due_resets_even_from_terminal(): void
|
||
{
|
||
$this->upserter->upsert($this->source, [$this->item()]);
|
||
Event::sole()->update(['state' => EventState::Done, 'done_at' => now(), 'quiet_until' => CarbonImmutable::now()->addDays(3)]);
|
||
|
||
$this->upserter->upsert($this->source, [$this->item(['due_at' => '2026-10-15'])]);
|
||
|
||
$event = Event::sole();
|
||
$this->assertSame(EventState::Preparing, $event->state);
|
||
$this->assertNull($event->done_at);
|
||
$this->assertTrue($event->quiet_until->lt(CarbonImmutable::now()));
|
||
$this->assertSame('2026-10-15', $event->due_date->format('Y-m-d'));
|
||
}
|
||
|
||
public function test_reschedule_on_the_day_resets_today_event(): void
|
||
{
|
||
// Задокументированное поведение: перенос в день срока сбрасывает состояние.
|
||
// Поэтому повторяющиеся события кладут период в topic, а не меняют due_at.
|
||
CarbonImmutable::setTestNow('2026-09-30T10:00:00-03:00');
|
||
$this->upserter->upsert($this->source, [$this->item()]);
|
||
$this->assertSame(EventState::Today, Event::sole()->state);
|
||
|
||
$this->upserter->upsert($this->source, [$this->item(['due_at' => '2027-09-30', 'fire_at' => '2027-08-31'])]);
|
||
$this->assertSame(EventState::Scheduled, Event::sole()->state);
|
||
}
|
||
|
||
public function test_occurrences_are_independent_rows(): void
|
||
{
|
||
CarbonImmutable::setTestNow('2026-10-05T10:00:00-03:00');
|
||
$this->upserter->upsert($this->source, [
|
||
$this->item(['source_ref' => 'rent', 'topic' => 'payment:2026-09', 'due_at' => '2026-09-10', 'fire_at' => '2026-09-10']),
|
||
$this->item(['source_ref' => 'rent', 'topic' => 'payment:2026-10', 'due_at' => '2026-10-10', 'fire_at' => '2026-10-10']),
|
||
]);
|
||
|
||
$states = Event::orderBy('topic')->pluck('state')->map->value->all();
|
||
$this->assertSame(['overdue', 'scheduled'], $states);
|
||
}
|
||
|
||
public function test_fixed_due_is_stored_as_instant(): void
|
||
{
|
||
$this->upserter->upsert($this->source, [$this->item(['due_at' => '2026-09-10T08:15+02:00', 'fire_at' => '2026-09-03'])]);
|
||
|
||
$event = Event::sole();
|
||
$this->assertSame('fixed', $event->due_mode->value);
|
||
$this->assertSame('2026-09-10T06:15:00+00:00', $event->due_instant->utc()->toIso8601String());
|
||
$this->assertNull($event->due_date);
|
||
}
|
||
|
||
public function test_withdraw_marks_withdrawn_and_reports_missing(): void
|
||
{
|
||
$this->upserter->upsert($this->source, [$this->item()]);
|
||
|
||
$this->assertTrue($this->upserter->withdraw($this->source, 'document:918', 'expiry'));
|
||
$this->assertSame(EventState::Withdrawn, Event::sole()->state);
|
||
$this->assertFalse($this->upserter->withdraw($this->source, 'document:918', 'other'));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "DueAtParserTest|EventUpserterTest"
|
||
```
|
||
Expected: FAIL, classes not found.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Ingest/ParsedDue.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Ingest;
|
||
|
||
use App\Enums\DueMode;
|
||
use App\Models\Event;
|
||
use Carbon\CarbonImmutable;
|
||
|
||
/** Разобранный due_at: tagged union по mode. */
|
||
final class ParsedDue
|
||
{
|
||
private function __construct(
|
||
public readonly DueMode $mode,
|
||
public readonly ?string $date,
|
||
public readonly ?string $time,
|
||
public readonly ?CarbonImmutable $instant,
|
||
) {}
|
||
|
||
public static function local(string $date, string $time): self
|
||
{
|
||
return new self(DueMode::Local, $date, $time, null);
|
||
}
|
||
|
||
public static function fixed(CarbonImmutable $instant): self
|
||
{
|
||
return new self(DueMode::Fixed, null, null, $instant->utc());
|
||
}
|
||
|
||
/** Календарный день срока в данном поясе, Y-m-d. */
|
||
public function dayFor(string $tz): string
|
||
{
|
||
return match ($this->mode) {
|
||
DueMode::Local => $this->date,
|
||
DueMode::Fixed => $this->instant->setTimezone($tz)->format('Y-m-d'),
|
||
};
|
||
}
|
||
|
||
public function equals(Event $event): bool
|
||
{
|
||
if ($event->due_mode !== $this->mode) {
|
||
return false;
|
||
}
|
||
|
||
return match ($this->mode) {
|
||
DueMode::Local => $event->due_date->format('Y-m-d') === $this->date && $event->due_time === $this->time,
|
||
DueMode::Fixed => $event->due_instant->eq($this->instant),
|
||
};
|
||
}
|
||
|
||
/** @return array<string, mixed> колонки events */
|
||
public function columns(): array
|
||
{
|
||
return [
|
||
'due_mode' => $this->mode,
|
||
'due_date' => $this->date,
|
||
'due_time' => $this->time,
|
||
'due_instant' => $this->instant,
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Ingest/DueAtParser.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Ingest;
|
||
|
||
use App\Scheduling\DueResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use InvalidArgumentException;
|
||
|
||
final class DueAtParser
|
||
{
|
||
private const DATE = '/^(\d{4})-(\d{2})-(\d{2})$/';
|
||
private const LOCAL_DATETIME = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/';
|
||
private const FIXED_DATETIME = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(Z|[+-]\d{2}:\d{2})$/';
|
||
|
||
public static function parse(string $raw): ParsedDue
|
||
{
|
||
if (preg_match(self::DATE, $raw, $m)) {
|
||
self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
|
||
|
||
return ParsedDue::local($raw, DueResolver::WHOLE_DAY);
|
||
}
|
||
|
||
if (preg_match(self::LOCAL_DATETIME, $raw, $m)) {
|
||
self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
|
||
self::assertTime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), $raw);
|
||
|
||
return ParsedDue::local(
|
||
sprintf('%s-%s-%s', $m[1], $m[2], $m[3]),
|
||
sprintf('%s:%s:%02d', $m[4], $m[5], (int) ($m[6] ?? 0)),
|
||
);
|
||
}
|
||
|
||
if (preg_match(self::FIXED_DATETIME, $raw)) {
|
||
try {
|
||
return ParsedDue::fixed(CarbonImmutable::parse($raw));
|
||
} catch (\Throwable $e) {
|
||
throw new InvalidArgumentException("due_at: невалидная дата-время «{$raw}»", 0, $e);
|
||
}
|
||
}
|
||
|
||
throw new InvalidArgumentException("due_at: ожидается YYYY-MM-DD, YYYY-MM-DDTHH:MM или дата-время со смещением, получено «{$raw}»");
|
||
}
|
||
|
||
private static function assertDate(int $y, int $m, int $d, string $raw): void
|
||
{
|
||
if (! checkdate($m, $d, $y)) {
|
||
throw new InvalidArgumentException("due_at: несуществующая дата «{$raw}»");
|
||
}
|
||
}
|
||
|
||
private static function assertTime(int $h, int $i, int $s, string $raw): void
|
||
{
|
||
if ($h > 23 || $i > 59 || $s > 59) {
|
||
throw new InvalidArgumentException("due_at: невалидное время «{$raw}»");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Ingest/EventUpserter.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Ingest;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use App\Scheduling\StateResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
final class EventUpserter
|
||
{
|
||
public function __construct(private readonly StateResolver $states) {}
|
||
|
||
/**
|
||
* @param list<array{user:string,source_ref:string,topic?:string,due_at:string,fire_at:string,after_due:string,payload:array}> $items
|
||
* @return list<array{source_ref:string,topic:string,id:int,state:string}>
|
||
*/
|
||
public function upsert(Source $source, array $items): array
|
||
{
|
||
return DB::transaction(function () use ($source, $items) {
|
||
$out = [];
|
||
foreach ($items as $item) {
|
||
$event = $this->upsertOne($source, $item);
|
||
$out[] = [
|
||
'source_ref' => $event->source_ref,
|
||
'topic' => $event->topic,
|
||
'id' => $event->id,
|
||
'state' => $event->state->value,
|
||
];
|
||
}
|
||
|
||
return $out;
|
||
});
|
||
}
|
||
|
||
public function withdraw(Source $source, string $sourceRef, string $topic): bool
|
||
{
|
||
$event = $this->find($source, $sourceRef, $topic);
|
||
if ($event === null) {
|
||
return false;
|
||
}
|
||
$event->update(['state' => EventState::Withdrawn]);
|
||
|
||
return true;
|
||
}
|
||
|
||
public function find(Source $source, string $sourceRef, string $topic): ?Event
|
||
{
|
||
return Event::where('source_id', $source->id)
|
||
->where('source_ref', $sourceRef)
|
||
->where('topic', $topic)
|
||
->first();
|
||
}
|
||
|
||
/** Пользователь по логину; создаётся с поясом по умолчанию и web-каналом. */
|
||
public function ensureUser(string $login): User
|
||
{
|
||
$user = User::firstOrCreate(
|
||
['login' => $login],
|
||
['tz' => config('hado.default_tz'), 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00'],
|
||
);
|
||
$user->channels()->firstOrCreate(['type' => ChannelType::Web->value], ['config' => [], 'enabled' => true]);
|
||
|
||
return $user;
|
||
}
|
||
|
||
private function upsertOne(Source $source, array $item): Event
|
||
{
|
||
$user = $this->ensureUser($item['user']);
|
||
$due = DueAtParser::parse($item['due_at']);
|
||
$topic = $item['topic'] ?? '';
|
||
$now = $user->now();
|
||
|
||
$attributes = [
|
||
'user_id' => $user->id,
|
||
'fire_on' => $item['fire_at'],
|
||
'after_due' => AfterDue::from($item['after_due']),
|
||
'payload' => $item['payload'],
|
||
] + $due->columns();
|
||
|
||
$event = $this->find($source, $item['source_ref'], $topic);
|
||
|
||
if ($event === null) {
|
||
$event = new Event([
|
||
'source_id' => $source->id,
|
||
'source_ref' => $item['source_ref'],
|
||
'topic' => $topic,
|
||
'state' => EventState::Scheduled,
|
||
'quiet_until' => $now->subMinute(),
|
||
] + $attributes);
|
||
$event->state = $this->states->resolve($event, $user, $now);
|
||
$event->save();
|
||
|
||
return $event;
|
||
}
|
||
|
||
$dueChanged = ! $due->equals($event);
|
||
$event->fill($attributes);
|
||
|
||
if ($dueChanged) {
|
||
// Перенос: новая дата — новый заход, даже из терминального состояния.
|
||
$event->done_at = null;
|
||
$event->quiet_until = $now->subMinute();
|
||
$event->state = $this->states->resolve($event, $user, $now);
|
||
} elseif (! $event->isTerminal()) {
|
||
$event->state = $this->states->resolve($event, $user, $now);
|
||
}
|
||
|
||
$event->save();
|
||
|
||
return $event;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "DueAtParserTest|EventUpserterTest"
|
||
```
|
||
Expected: 12 tests PASS. If `test_new_due_resets_even_from_terminal` fails on `quiet_until` comparison because SQLite drops sub-second precision, compare with `lte(now())` instead of `lt` — the semantic is «в прошлом или сейчас».
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: due_at parser and event upserter
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Source tokens and the client API
|
||
|
||
**Files:**
|
||
- Create: `core/app/Http/Middleware/AuthenticateSource.php`, `core/app/Console/Commands/SourceCreate.php`, `core/app/Http/Requests/UpsertEventsRequest.php`, `core/app/Http/Controllers/Api/EventsController.php`, `core/routes/api.php`
|
||
- Modify: `core/bootstrap/app.php` (register `api` routes, middleware alias, JSON exceptions)
|
||
- Test: `core/tests/Feature/ClientApiTest.php`, `core/tests/Feature/SourceCreateCommandTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EventUpserter`, `DueAtParser`.
|
||
- Produces: routes `PUT /api/events`, `DELETE /api/events?source_ref&topic`, `GET /api/events?source_ref&topic`; request attribute `source` (`Source` model) set by middleware alias `auth.source`; artisan `hado:source:create {name}` printing `Token: hado_<64 hex>` once. Token hash = `hash('sha256', $token)`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`core/tests/Feature/SourceCreateCommandTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Models\Source;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class SourceCreateCommandTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
public function test_creates_source_and_prints_token_once(): void
|
||
{
|
||
$this->artisan('hado:source:create', ['name' => 'docs'])
|
||
->expectsOutputToContain('Token: hado_')
|
||
->assertSuccessful();
|
||
|
||
$source = Source::where('name', 'docs')->sole();
|
||
$this->assertSame(64, strlen($source->token_hash));
|
||
}
|
||
|
||
public function test_refuses_duplicate_name(): void
|
||
{
|
||
Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
|
||
$this->artisan('hado:source:create', ['name' => 'docs'])->assertFailed();
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Feature/ClientApiTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class ClientApiTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private const TOKEN = 'hado_testtoken';
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
CarbonImmutable::setTestNow('2026-09-01T12:00:00Z');
|
||
Source::create(['name' => 'docs', 'token_hash' => hash('sha256', self::TOKEN)]);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
private function item(array $over = []): array
|
||
{
|
||
return array_replace([
|
||
'user' => 'nikita', 'source_ref' => 'person:42', 'topic' => 'birthday:2026',
|
||
'due_at' => '2026-08-14', 'fire_at' => '2026-08-14', 'after_due' => 'expire',
|
||
'payload' => ['title' => 'Сегодня ДР — Вася', 'deep_link' => '/people/42'],
|
||
], $over);
|
||
}
|
||
|
||
private function auth(): array
|
||
{
|
||
return ['Authorization' => 'Bearer '.self::TOKEN];
|
||
}
|
||
|
||
public function test_rejects_missing_or_wrong_token(): void
|
||
{
|
||
$this->putJson('/api/events', ['events' => [$this->item()]])->assertStatus(401);
|
||
$this->putJson('/api/events', ['events' => [$this->item()]], ['Authorization' => 'Bearer nope'])->assertStatus(401);
|
||
}
|
||
|
||
public function test_upserts_batch_and_returns_ids_and_states(): void
|
||
{
|
||
$this->putJson('/api/events', ['events' => [$this->item()]], $this->auth())
|
||
->assertOk()
|
||
->assertJsonPath('events.0.source_ref', 'person:42')
|
||
->assertJsonPath('events.0.topic', 'birthday:2026')
|
||
->assertJsonPath('events.0.state', 'expired');
|
||
|
||
$this->assertSame(1, Event::count());
|
||
}
|
||
|
||
public function test_validation_rejects_whole_batch_with_index(): void
|
||
{
|
||
$bad = $this->item(['due_at' => 'tomorrow']);
|
||
$this->putJson('/api/events', ['events' => [$this->item(), $bad]], $this->auth())
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['events.1.due_at']);
|
||
|
||
$this->assertSame(0, Event::count(), 'ничего не применилось');
|
||
}
|
||
|
||
public function test_validation_rules(): void
|
||
{
|
||
$this->putJson('/api/events', ['events' => [$this->item(['fire_at' => '2026-08-15'])]], $this->auth())
|
||
->assertStatus(422)->assertJsonValidationErrors(['events.0.fire_at']);
|
||
$this->putJson('/api/events', ['events' => [$this->item(['after_due' => 'maybe'])]], $this->auth())
|
||
->assertStatus(422)->assertJsonValidationErrors(['events.0.after_due']);
|
||
$this->putJson('/api/events', ['events' => [$this->item(['payload' => ['subtitle' => 'x']])]], $this->auth())
|
||
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.title']);
|
||
$this->putJson('/api/events', ['events' => [$this->item(['user' => ''])]], $this->auth())
|
||
->assertStatus(422)->assertJsonValidationErrors(['events.0.user']);
|
||
$this->putJson('/api/events', ['events' => array_fill(0, 501, $this->item())], $this->auth())
|
||
->assertStatus(422)->assertJsonValidationErrors(['events']);
|
||
}
|
||
|
||
public function test_topic_defaults_to_empty_string(): void
|
||
{
|
||
$item = $this->item();
|
||
unset($item['topic']);
|
||
$this->putJson('/api/events', ['events' => [$item]], $this->auth())->assertOk()->assertJsonPath('events.0.topic', '');
|
||
}
|
||
|
||
public function test_get_and_delete_by_identity(): void
|
||
{
|
||
$this->putJson('/api/events', ['events' => [$this->item(['due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep'])]], $this->auth());
|
||
|
||
$this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', $this->auth())
|
||
->assertOk()
|
||
->assertJsonPath('state', 'preparing')
|
||
->assertJsonPath('due_at', '2026-09-30')
|
||
->assertJsonPath('topic', 'birthday:2026');
|
||
|
||
$this->deleteJson('/api/events?source_ref=person:42&topic=birthday:2026', [], $this->auth())->assertNoContent();
|
||
$this->assertSame(EventState::Withdrawn, Event::sole()->state);
|
||
|
||
$this->getJson('/api/events?source_ref=person:42&topic=nope', $this->auth())->assertNotFound();
|
||
$this->deleteJson('/api/events?source_ref=person:42&topic=nope', [], $this->auth())->assertNotFound();
|
||
}
|
||
|
||
public function test_source_is_isolated_by_token(): void
|
||
{
|
||
Source::create(['name' => 'other', 'token_hash' => hash('sha256', 'other-token')]);
|
||
$this->putJson('/api/events', ['events' => [$this->item()]], $this->auth());
|
||
|
||
$this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', ['Authorization' => 'Bearer other-token'])->assertNotFound();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "ClientApiTest|SourceCreateCommandTest"
|
||
```
|
||
Expected: FAIL (404s / command not found).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Http/Middleware/AuthenticateSource.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Middleware;
|
||
|
||
use App\Models\Source;
|
||
use Closure;
|
||
use Illuminate\Http\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
class AuthenticateSource
|
||
{
|
||
public function handle(Request $request, Closure $next): Response
|
||
{
|
||
$token = $request->bearerToken();
|
||
if ($token === null || $token === '') {
|
||
return response()->json(['message' => 'Нужен Bearer-токен источника'], 401);
|
||
}
|
||
|
||
$source = Source::where('token_hash', hash('sha256', $token))->first();
|
||
if ($source === null) {
|
||
return response()->json(['message' => 'Неизвестный токен источника'], 401);
|
||
}
|
||
|
||
$request->attributes->set('source', $source);
|
||
|
||
return $next($request);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Console/Commands/SourceCreate.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Source;
|
||
use Illuminate\Console\Command;
|
||
|
||
class SourceCreate extends Command
|
||
{
|
||
protected $signature = 'hado:source:create {name : Имя источника, напр. docs}';
|
||
|
||
protected $description = 'Регистрирует сервис-клиент и печатает его токен (один раз)';
|
||
|
||
public function handle(): int
|
||
{
|
||
$name = (string) $this->argument('name');
|
||
if (Source::where('name', $name)->exists()) {
|
||
$this->error("Источник «{$name}» уже существует");
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$token = 'hado_'.bin2hex(random_bytes(32));
|
||
Source::create(['name' => $name, 'token_hash' => hash('sha256', $token)]);
|
||
|
||
$this->info("Источник «{$name}» создан. Токен показывается один раз, в базе только хэш.");
|
||
$this->line("Token: {$token}");
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Http/Requests/UpsertEventsRequest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Requests;
|
||
|
||
use App\Ingest\DueAtParser;
|
||
use Illuminate\Foundation\Http\FormRequest;
|
||
use Illuminate\Validation\Validator;
|
||
use InvalidArgumentException;
|
||
|
||
class UpsertEventsRequest extends FormRequest
|
||
{
|
||
public function authorize(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function rules(): array
|
||
{
|
||
return [
|
||
'events' => ['required', 'array', 'min:1', 'max:500'],
|
||
'events.*.user' => ['required', 'string', 'max:255'],
|
||
'events.*.source_ref' => ['required', 'string', 'max:255'],
|
||
'events.*.topic' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||
'events.*.due_at' => ['required', 'string'],
|
||
'events.*.fire_at' => ['required', 'date_format:Y-m-d'],
|
||
'events.*.after_due' => ['required', 'in:keep,expire'],
|
||
'events.*.payload' => ['required', 'array'],
|
||
'events.*.payload.title' => ['required', 'string'],
|
||
'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'],
|
||
'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string'],
|
||
'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||
];
|
||
}
|
||
|
||
public function after(): array
|
||
{
|
||
return [function (Validator $v) {
|
||
foreach ((array) $this->input('events', []) as $i => $item) {
|
||
if (! is_array($item) || ! isset($item['due_at'], $item['fire_at']) || ! is_string($item['due_at'])) {
|
||
continue;
|
||
}
|
||
try {
|
||
$due = DueAtParser::parse($item['due_at']);
|
||
} catch (InvalidArgumentException $e) {
|
||
$v->errors()->add("events.$i.due_at", $e->getMessage());
|
||
continue;
|
||
}
|
||
// Проверка fire_at <= день срока — по поясу по умолчанию; для fixed это
|
||
// приближение, точный день пользователя известен только при upsert.
|
||
if ($v->errors()->missing("events.$i.fire_at") && $item['fire_at'] > $due->dayFor(config('hado.default_tz'))) {
|
||
$v->errors()->add("events.$i.fire_at", 'fire_at не может быть позже дня срока');
|
||
}
|
||
}
|
||
}];
|
||
}
|
||
|
||
/** @return list<array<string,mixed>> */
|
||
public function items(): array
|
||
{
|
||
return array_map(function (array $item) {
|
||
$item['topic'] = (string) ($item['topic'] ?? '');
|
||
|
||
return $item;
|
||
}, $this->validated()['events']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Http/Controllers/Api/EventsController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\UpsertEventsRequest;
|
||
use App\Ingest\EventUpserter;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
|
||
class EventsController extends Controller
|
||
{
|
||
public function __construct(private readonly EventUpserter $upserter) {}
|
||
|
||
public function upsert(UpsertEventsRequest $request): JsonResponse
|
||
{
|
||
/** @var Source $source */
|
||
$source = $request->attributes->get('source');
|
||
|
||
return response()->json(['events' => $this->upserter->upsert($source, $request->items())]);
|
||
}
|
||
|
||
public function show(Request $request): JsonResponse
|
||
{
|
||
$event = $this->lookup($request);
|
||
|
||
return response()->json(self::present($event));
|
||
}
|
||
|
||
public function destroy(Request $request): Response
|
||
{
|
||
$event = $this->lookup($request);
|
||
$event->update(['state' => \App\Enums\EventState::Withdrawn]);
|
||
|
||
return response()->noContent();
|
||
}
|
||
|
||
private function lookup(Request $request): Event
|
||
{
|
||
$request->validate(['source_ref' => ['required', 'string'], 'topic' => ['sometimes', 'nullable', 'string']]);
|
||
/** @var Source $source */
|
||
$source = $request->attributes->get('source');
|
||
$event = $this->upserter->find($source, $request->query('source_ref'), (string) $request->query('topic', ''));
|
||
abort_if($event === null, 404, 'Событие не найдено');
|
||
|
||
return $event;
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function present(Event $event): array
|
||
{
|
||
return [
|
||
'id' => $event->id,
|
||
'source_ref' => $event->source_ref,
|
||
'topic' => $event->topic,
|
||
'state' => $event->state->value,
|
||
'due_at' => self::dueAt($event),
|
||
'fire_at' => $event->fire_on->format('Y-m-d'),
|
||
'after_due' => $event->after_due->value,
|
||
'payload' => $event->payload,
|
||
'done_at' => $event->done_at?->toIso8601String(),
|
||
];
|
||
}
|
||
|
||
private static function dueAt(Event $event): string
|
||
{
|
||
if ($event->due_mode === \App\Enums\DueMode::Fixed) {
|
||
return $event->due_instant->toIso8601String();
|
||
}
|
||
$date = $event->due_date->format('Y-m-d');
|
||
|
||
return $event->due_time === \App\Scheduling\DueResolver::WHOLE_DAY ? $date : $date.'T'.substr($event->due_time, 0, 5);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/routes/api.php`:
|
||
```php
|
||
<?php
|
||
|
||
use App\Http\Controllers\Api\EventsController;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
Route::middleware('auth.source')->group(function () {
|
||
Route::put('/events', [EventsController::class, 'upsert']);
|
||
Route::get('/events', [EventsController::class, 'show']);
|
||
Route::delete('/events', [EventsController::class, 'destroy']);
|
||
});
|
||
```
|
||
|
||
`core/bootstrap/app.php` — register api routes and the alias:
|
||
```php
|
||
<?php
|
||
|
||
use Illuminate\Foundation\Application;
|
||
use Illuminate\Foundation\Configuration\Exceptions;
|
||
use Illuminate\Foundation\Configuration\Middleware;
|
||
use Illuminate\Http\Request;
|
||
|
||
return Application::configure(basePath: dirname(__DIR__))
|
||
->withRouting(
|
||
web: __DIR__.'/../routes/web.php',
|
||
api: __DIR__.'/../routes/api.php',
|
||
commands: __DIR__.'/../routes/console.php',
|
||
health: '/up',
|
||
)
|
||
->withMiddleware(function (Middleware $middleware): void {
|
||
$middleware->trustProxies(at: '*');
|
||
$middleware->alias([
|
||
'auth.source' => \App\Http\Middleware\AuthenticateSource::class,
|
||
'remote.user' => \App\Http\Middleware\RemoteUser::class,
|
||
]);
|
||
$middleware->validateCsrfTokens(except: ['a/*', 'hooks/*']);
|
||
})
|
||
->withExceptions(function (Exceptions $exceptions): void {
|
||
$exceptions->shouldRenderJsonWhen(
|
||
fn (Request $request) => $request->expectsJson() || $request->is('api/*') || $request->is('a/*') || $request->is('hooks/*'),
|
||
);
|
||
})->create();
|
||
```
|
||
|
||
`RemoteUser` is created in Task 7; until then create an empty pass-through class so the alias resolves:
|
||
|
||
`core/app/Http/Middleware/RemoteUser.php` (temporary, replaced in Task 7):
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Middleware;
|
||
|
||
use Closure;
|
||
use Illuminate\Http\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
class RemoteUser
|
||
{
|
||
public function handle(Request $request, Closure $next): Response
|
||
{
|
||
return $next($request);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "ClientApiTest|SourceCreateCommandTest"
|
||
```
|
||
Expected: 9 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: source tokens and client events API
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: RemoteUser middleware, EventActions and the `/me` API
|
||
|
||
**Files:**
|
||
- Replace: `core/app/Http/Middleware/RemoteUser.php`
|
||
- Create: `core/app/Delivery/EventActions.php`, `core/app/Http/Controllers/Me/ProfileController.php`, `core/app/Http/Controllers/Me/EventsController.php`
|
||
- Modify: `core/routes/web.php`
|
||
- Test: `core/tests/Feature/MeApiTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `QuietRule`, `DueResolver`, `EventUpserter::ensureUser`, `ChannelRegistry` (Task 8; for `onDone` — until Task 8 exists, `EventActions::done` only changes state; Task 8 adds the hook).
|
||
- Produces:
|
||
- middleware alias `remote.user`: reads `X-Remote-User`, 401 if missing, `ensureUser`, sets request attribute `user`.
|
||
- `EventActions::ack(Event): void`, `EventActions::done(Event): void` (idempotent on terminal events).
|
||
- Routes: `GET /me`, `PATCH /me`, `GET /me/events?state=`, `POST /me/events/{event}/ack`, `POST /me/events/{event}/done`, `POST /me/heartbeat`.
|
||
- Heartbeat cache key: `heartbeat:{user_id}`, TTL `hado.web_heartbeat_ttl`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`core/tests/Feature/MeApiTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Tests\TestCase;
|
||
|
||
class MeApiTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private Source $source;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
config(['hado.default_tz' => 'UTC']);
|
||
CarbonImmutable::setTestNow('2026-09-30T11:17:00Z');
|
||
$this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
private function as(string $login = 'nikita'): array
|
||
{
|
||
return ['X-Remote-User' => $login];
|
||
}
|
||
|
||
private function event(User $user, EventState $state, string $topic = 't', string $due = '2026-09-30', string $fireOn = '2026-09-30'): Event
|
||
{
|
||
return Event::create([
|
||
'source_id' => $this->source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => '24:00:00', 'fire_on' => $fireOn,
|
||
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => $state,
|
||
'quiet_until' => CarbonImmutable::now()->subDay(),
|
||
]);
|
||
}
|
||
|
||
public function test_requires_remote_user_header(): void
|
||
{
|
||
$this->getJson('/me')->assertStatus(401);
|
||
}
|
||
|
||
public function test_first_visit_creates_user_with_web_channel(): void
|
||
{
|
||
$this->getJson('/me', $this->as())->assertOk()
|
||
->assertJsonPath('login', 'nikita')
|
||
->assertJsonPath('tz', 'UTC')
|
||
->assertJsonPath('quiet_start', '22:00')
|
||
->assertJsonPath('quiet_end', '09:00')
|
||
->assertJsonPath('badge', 0);
|
||
|
||
$this->assertSame(ChannelType::Web, User::sole()->channels()->sole()->type);
|
||
}
|
||
|
||
public function test_badge_counts_today_and_overdue_only(): void
|
||
{
|
||
$this->getJson('/me', $this->as());
|
||
$user = User::sole();
|
||
$this->event($user, EventState::Today, 'a');
|
||
$this->event($user, EventState::Overdue, 'b');
|
||
$this->event($user, EventState::Preparing, 'c');
|
||
$this->event($user, EventState::Done, 'd');
|
||
|
||
$this->getJson('/me', $this->as())->assertJsonPath('badge', 2);
|
||
}
|
||
|
||
public function test_patch_profile_validates_timezone_and_quiet_hours(): void
|
||
{
|
||
$this->patchJson('/me', ['tz' => 'Asia/Shanghai', 'quiet_start' => '23:00', 'quiet_end' => '08:00'], $this->as())
|
||
->assertOk()->assertJsonPath('tz', 'Asia/Shanghai')->assertJsonPath('quiet_start', '23:00');
|
||
|
||
$this->patchJson('/me', ['tz' => 'Mars/Olympus'], $this->as())->assertStatus(422);
|
||
$this->patchJson('/me', ['quiet_start' => '08:00', 'quiet_end' => '22:00'], $this->as())
|
||
->assertStatus(422)->assertJsonValidationErrors(['quiet_end']);
|
||
}
|
||
|
||
public function test_lists_own_non_terminal_events(): void
|
||
{
|
||
$this->getJson('/me', $this->as());
|
||
$this->getJson('/me', $this->as('other'));
|
||
$me = User::where('login', 'nikita')->sole();
|
||
$other = User::where('login', 'other')->sole();
|
||
$this->event($me, EventState::Today, 'a');
|
||
$this->event($me, EventState::Done, 'b');
|
||
$this->event($other, EventState::Today, 'c');
|
||
|
||
$this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a');
|
||
$this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b');
|
||
}
|
||
|
||
public function test_ack_sets_quiet_until_and_done_closes(): void
|
||
{
|
||
$this->getJson('/me', $this->as());
|
||
$user = User::sole();
|
||
$today = $this->event($user, EventState::Today, 'a');
|
||
$done = $this->event($user, EventState::Preparing, 'b', '2026-10-30', '2026-09-30');
|
||
|
||
$this->postJson("/me/events/{$today->id}/ack", [], $this->as())->assertOk()->assertJsonPath('state', 'today');
|
||
$this->assertSame('2026-09-30T13:00:00+00:00', $today->fresh()->quiet_until->toIso8601String());
|
||
|
||
$this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk()->assertJsonPath('state', 'done');
|
||
$this->assertNotNull($done->fresh()->done_at);
|
||
|
||
$this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk(); // идемпотентно
|
||
}
|
||
|
||
public function test_cannot_touch_someone_elses_event(): void
|
||
{
|
||
$this->getJson('/me', $this->as('other'));
|
||
$event = $this->event(User::sole(), EventState::Today);
|
||
|
||
$this->postJson("/me/events/{$event->id}/done", [], $this->as('nikita'))->assertNotFound();
|
||
}
|
||
|
||
public function test_heartbeat_marks_web_presence(): void
|
||
{
|
||
$this->postJson('/me/heartbeat', [], $this->as())->assertNoContent();
|
||
$this->assertTrue(Cache::has('heartbeat:'.User::sole()->id));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter MeApiTest
|
||
```
|
||
Expected: FAIL (401 → pass-through middleware gives 404/500).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Http/Middleware/RemoteUser.php` (replace):
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Middleware;
|
||
|
||
use App\Ingest\EventUpserter;
|
||
use Closure;
|
||
use Illuminate\Http\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
/**
|
||
* Доверяет X-Remote-User от Caddy forward_auth (сеть изолирована, как в sekai).
|
||
* На публичных маршрутах (/api, /a, /hooks) этот middleware не стоит.
|
||
*/
|
||
class RemoteUser
|
||
{
|
||
public function __construct(private readonly EventUpserter $users) {}
|
||
|
||
public function handle(Request $request, Closure $next): Response
|
||
{
|
||
$login = trim((string) $request->header('X-Remote-User', ''));
|
||
if ($login === '') {
|
||
return response()->json(['message' => 'Нет X-Remote-User: запрос должен идти через хаб'], 401);
|
||
}
|
||
|
||
$request->attributes->set('user', $this->users->ensureUser($login));
|
||
|
||
return $next($request);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Delivery/EventActions.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Delivery;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Scheduling\DueResolver;
|
||
use App\Scheduling\QuietRule;
|
||
|
||
final class EventActions
|
||
{
|
||
public function __construct(
|
||
private readonly DueResolver $due,
|
||
private readonly QuietRule $quiet,
|
||
) {}
|
||
|
||
/** «Помню»: молчать до следующей контрольной точки текущего состояния. */
|
||
public function ack(Event $event): void
|
||
{
|
||
if ($event->isTerminal()) {
|
||
return;
|
||
}
|
||
$user = $event->user;
|
||
$now = $user->now();
|
||
$event->quiet_until = $this->quiet->afterAck($event, $user, $event->state, $this->due->resolve($event, $user), $now);
|
||
$event->save();
|
||
}
|
||
|
||
/** «Сделано»: закрыть событие целиком. */
|
||
public function done(Event $event): void
|
||
{
|
||
if ($event->isTerminal()) {
|
||
return;
|
||
}
|
||
$event->state = EventState::Done;
|
||
$event->done_at = now();
|
||
$event->save();
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Http/Controllers/Me/ProfileController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Me;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\User;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Validation\Rule;
|
||
use Illuminate\Validation\Validator;
|
||
|
||
class ProfileController extends Controller
|
||
{
|
||
public function show(Request $request): JsonResponse
|
||
{
|
||
return response()->json(self::present($request->attributes->get('user')));
|
||
}
|
||
|
||
public function update(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
|
||
$data = $request->validate([
|
||
'tz' => ['sometimes', 'string', Rule::in(\DateTimeZone::listIdentifiers())],
|
||
'quiet_start' => ['sometimes', 'date_format:H:i'],
|
||
'quiet_end' => ['sometimes', 'date_format:H:i'],
|
||
]);
|
||
|
||
$start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5);
|
||
$end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5);
|
||
if ($end >= $start) {
|
||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||
'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start',
|
||
]);
|
||
}
|
||
|
||
$user->fill([
|
||
'tz' => $data['tz'] ?? $user->tz,
|
||
'quiet_start' => $start.':00',
|
||
'quiet_end' => $end.':00',
|
||
])->save();
|
||
|
||
return response()->json(self::present($user));
|
||
}
|
||
|
||
public function heartbeat(Request $request): Response
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
Cache::put("heartbeat:{$user->id}", true, config('hado.web_heartbeat_ttl'));
|
||
|
||
return response()->noContent();
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function present(User $user): array
|
||
{
|
||
return [
|
||
'login' => $user->login,
|
||
'tz' => $user->tz,
|
||
'quiet_start' => substr($user->quiet_start, 0, 5),
|
||
'quiet_end' => substr($user->quiet_end, 0, 5),
|
||
'badge' => $user->events()->whereIn('state', [EventState::Today->value, EventState::Overdue->value])->count(),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Http/Controllers/Me/EventsController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Me;
|
||
|
||
use App\Delivery\EventActions;
|
||
use App\Enums\EventState;
|
||
use App\Http\Controllers\Api\EventsController as ApiPresenter;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
|
||
class EventsController extends Controller
|
||
{
|
||
public function __construct(private readonly EventActions $actions) {}
|
||
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
$states = $request->query('state')
|
||
? [$request->query('state')]
|
||
: EventState::nonTerminalValues();
|
||
|
||
$events = $user->events()->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get();
|
||
|
||
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]);
|
||
}
|
||
|
||
public function ack(Request $request, int $event): JsonResponse
|
||
{
|
||
$model = $this->own($request, $event);
|
||
$this->actions->ack($model);
|
||
|
||
return response()->json(ApiPresenter::present($model->fresh()));
|
||
}
|
||
|
||
public function done(Request $request, int $event): JsonResponse
|
||
{
|
||
$model = $this->own($request, $event);
|
||
$this->actions->done($model);
|
||
|
||
return response()->json(ApiPresenter::present($model->fresh()));
|
||
}
|
||
|
||
private function own(Request $request, int $id): Event
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
|
||
return $user->events()->whereKey($id)->firstOrFail();
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/routes/web.php` (replace):
|
||
```php
|
||
<?php
|
||
|
||
use App\Http\Controllers\Me\EventsController as MeEvents;
|
||
use App\Http\Controllers\Me\ProfileController;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
Route::middleware('remote.user')->group(function () {
|
||
Route::get('/me', [ProfileController::class, 'show']);
|
||
Route::patch('/me', [ProfileController::class, 'update']);
|
||
Route::post('/me/heartbeat', [ProfileController::class, 'heartbeat']);
|
||
Route::get('/me/events', [MeEvents::class, 'index']);
|
||
Route::post('/me/events/{event}/ack', [MeEvents::class, 'ack']);
|
||
Route::post('/me/events/{event}/done', [MeEvents::class, 'done']);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter MeApiTest
|
||
```
|
||
Expected: 8 tests PASS. Note `firstOrFail` inside `own()` yields 404 for foreign events, as the test expects.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: remote user, ack/done actions and /me API
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Channel drivers (web, webhook, telegram) and registry
|
||
|
||
**Files:**
|
||
- Create: `core/app/Channels/ChannelDriver.php`, `DeliveryOutcome.php`, `ChannelRegistry.php`, `WebChannel.php`, `WebhookChannel.php`, `TelegramChannel.php`
|
||
- Modify: `core/app/Delivery/EventActions.php` (call `onDone` for each delivery), `core/app/Providers/AppServiceProvider.php` (bind registry)
|
||
- Test: `core/tests/Unit/Channels/WebhookChannelTest.php`, `core/tests/Unit/Channels/TelegramChannelTest.php`, `core/tests/Unit/Channels/WebChannelTest.php`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```php
|
||
interface ChannelDriver {
|
||
public function presence(User $user, array $config): Presence;
|
||
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome;
|
||
public function onDone(Event $event, Delivery $delivery): void; // best effort, never throws
|
||
}
|
||
final class DeliveryOutcome { bool $ok; ?string $error; array $meta; static ok(array $meta = []); static failed(string $error); }
|
||
final class ChannelRegistry { public function for(ChannelType $type): ChannelDriver; }
|
||
```
|
||
- Action URLs: `route('action.ack', $delivery->action_token)` / `route('action.done', ...)` — routes are defined in Task 10; the drivers call `route()` so Task 10 must name them exactly `action.ack` and `action.done`. In unit tests of this task, register the two routes in `setUp` (see tests).
|
||
- `TelegramChannel::touch(int|string $chatId): void` marks the chat as recently seen (cache `tg:seen:{chat}`, TTL `hado.telegram.seen_ttl`).
|
||
- `TelegramChannel::sendText(int|string $chatId, string $text): void`, `TelegramChannel::answerCallback(string $id, string $text): void`.
|
||
- Webhook deliver payload: `{ event: {id, state, due_at, source, title, subtitle, deep_link, done_label}, actions: {ack, done} }`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`core/tests/Unit/Channels/WebChannelTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Channels;
|
||
|
||
use App\Channels\WebChannel;
|
||
use App\Enums\Presence;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Tests\TestCase;
|
||
|
||
class WebChannelTest extends TestCase
|
||
{
|
||
public function test_presence_follows_heartbeat(): void
|
||
{
|
||
$user = new User(['login' => 'n', 'tz' => 'UTC']);
|
||
$user->id = 7;
|
||
$driver = new WebChannel;
|
||
|
||
$this->assertSame(Presence::Absent, $driver->presence($user, []));
|
||
Cache::put('heartbeat:7', true, 90);
|
||
$this->assertSame(Presence::Present, $driver->presence($user, []));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Unit/Channels/WebhookChannelTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Channels;
|
||
|
||
use App\Channels\WebhookChannel;
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Enums\Presence;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Illuminate\Http\Client\ConnectionException;
|
||
use Illuminate\Http\Client\Request;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Route;
|
||
use Tests\TestCase;
|
||
|
||
class WebhookChannelTest extends TestCase
|
||
{
|
||
private array $config = ['deliver_url' => 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/hado/presence'];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Route::post('/a/{token}/ack', fn () => '')->name('action.ack');
|
||
Route::post('/a/{token}/done', fn () => '')->name('action.done');
|
||
}
|
||
|
||
private function driver(): WebhookChannel
|
||
{
|
||
return new WebhookChannel(2);
|
||
}
|
||
|
||
public function test_presence_from_json(): void
|
||
{
|
||
Http::fake([
|
||
'ha.test/api/hado/presence' => Http::sequence()
|
||
->push(['present' => true])
|
||
->push(['present' => false])
|
||
->push('oops', 500),
|
||
]);
|
||
$user = new User(['login' => 'n', 'tz' => 'UTC']);
|
||
|
||
$this->assertSame(Presence::Present, $this->driver()->presence($user, $this->config));
|
||
$this->assertSame(Presence::Absent, $this->driver()->presence($user, $this->config));
|
||
$this->assertSame(Presence::Unknown, $this->driver()->presence($user, $this->config));
|
||
}
|
||
|
||
public function test_presence_unknown_on_timeout(): void
|
||
{
|
||
Http::fake(fn () => throw new ConnectionException('timeout'));
|
||
$this->assertSame(Presence::Unknown, $this->driver()->presence(new User(['login' => 'n', 'tz' => 'UTC']), $this->config));
|
||
}
|
||
|
||
public function test_deliver_posts_event_and_action_urls(): void
|
||
{
|
||
Http::fake(['ha.test/api/webhook/hado' => Http::response('', 200)]);
|
||
|
||
$event = new Event([
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14',
|
||
'after_due' => AfterDue::Expire, 'state' => EventState::Today,
|
||
'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'deep_link' => '/people/42', 'done_label' => 'Поздравил'],
|
||
]);
|
||
$event->id = 17;
|
||
$event->setRelation('source', new Source(['name' => 'docs']));
|
||
$delivery = new Delivery(['action_token' => 'tok123']);
|
||
|
||
$outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, $delivery);
|
||
|
||
$this->assertTrue($outcome->ok);
|
||
Http::assertSent(function (Request $r) {
|
||
return $r->url() === 'https://ha.test/api/webhook/hado'
|
||
&& $r['event']['id'] === 17
|
||
&& $r['event']['title'] === 'Сегодня ДР — Вася'
|
||
&& $r['event']['done_label'] === 'Поздравил'
|
||
&& $r['event']['source'] === 'docs'
|
||
&& $r['event']['due_at'] === '2026-08-14'
|
||
&& str_ends_with($r['actions']['ack'], '/a/tok123/ack')
|
||
&& str_ends_with($r['actions']['done'], '/a/tok123/done');
|
||
});
|
||
}
|
||
|
||
public function test_deliver_reports_failure(): void
|
||
{
|
||
Http::fake(['ha.test/api/webhook/hado' => Http::response('nope', 503)]);
|
||
$event = new Event(['due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', 'after_due' => AfterDue::Expire, 'state' => EventState::Today, 'payload' => ['title' => 't']]);
|
||
$event->setRelation('source', new Source(['name' => 'docs']));
|
||
|
||
$outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, new Delivery(['action_token' => 'x']));
|
||
|
||
$this->assertFalse($outcome->ok);
|
||
$this->assertStringContainsString('503', $outcome->error);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Unit/Channels/TelegramChannelTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Unit\Channels;
|
||
|
||
use App\Channels\TelegramChannel;
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Enums\Presence;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Illuminate\Http\Client\Request;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Tests\TestCase;
|
||
|
||
class TelegramChannelTest extends TestCase
|
||
{
|
||
private function driver(): TelegramChannel
|
||
{
|
||
return new TelegramChannel('test-token', 600);
|
||
}
|
||
|
||
private function event(): Event
|
||
{
|
||
$event = new Event([
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14',
|
||
'after_due' => AfterDue::Expire, 'state' => EventState::Today,
|
||
'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'done_label' => 'Поздравил'],
|
||
]);
|
||
$event->id = 17;
|
||
$event->setRelation('source', new Source(['name' => 'docs']));
|
||
|
||
return $event;
|
||
}
|
||
|
||
public function test_presence_is_unknown_unless_recently_seen(): void
|
||
{
|
||
$user = new User(['login' => 'n', 'tz' => 'UTC']);
|
||
$this->assertSame(Presence::Unknown, $this->driver()->presence($user, ['chat_id' => 555]));
|
||
|
||
$this->driver()->touch(555);
|
||
$this->assertSame(Presence::Present, $this->driver()->presence($user, ['chat_id' => 555]));
|
||
}
|
||
|
||
public function test_deliver_sends_message_with_two_buttons_and_stores_message_id(): void
|
||
{
|
||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => ['message_id' => 9001]])]);
|
||
|
||
$outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok']));
|
||
|
||
$this->assertTrue($outcome->ok);
|
||
$this->assertSame(['message_id' => 9001], $outcome->meta);
|
||
Http::assertSent(function (Request $r) {
|
||
$kb = $r['reply_markup']['inline_keyboard'][0];
|
||
|
||
return str_ends_with($r->url(), '/bottest-token/sendMessage')
|
||
&& $r['chat_id'] === 555
|
||
&& str_contains($r['text'], 'Сегодня ДР — Вася')
|
||
&& str_contains($r['text'], 'исполняется 34')
|
||
&& $kb[0] === ['text' => 'Помню', 'callback_data' => 'ack:tok']
|
||
&& $kb[1] === ['text' => 'Поздравил', 'callback_data' => 'done:tok'];
|
||
});
|
||
}
|
||
|
||
public function test_deliver_fails_on_api_error(): void
|
||
{
|
||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400)]);
|
||
|
||
$outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok']));
|
||
|
||
$this->assertFalse($outcome->ok);
|
||
$this->assertStringContainsString('chat not found', $outcome->error);
|
||
}
|
||
|
||
public function test_on_done_clears_buttons_best_effort(): void
|
||
{
|
||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true])]);
|
||
$delivery = new Delivery(['action_token' => 'tok', 'meta' => ['message_id' => 9001]]);
|
||
$delivery->setRelation('channel', new \App\Models\Channel(['config' => ['chat_id' => 555]]));
|
||
|
||
$this->driver()->onDone($this->event(), $delivery);
|
||
|
||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001 && $r['chat_id'] === 555);
|
||
|
||
Http::fake(fn () => throw new \RuntimeException('down'));
|
||
$this->driver()->onDone($this->event(), $delivery); // не бросает
|
||
$this->addToAssertionCount(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "WebChannelTest|WebhookChannelTest|TelegramChannelTest"
|
||
```
|
||
Expected: FAIL, classes not found.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Channels/DeliveryOutcome.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Channels;
|
||
|
||
final class DeliveryOutcome
|
||
{
|
||
private function __construct(
|
||
public readonly bool $ok,
|
||
public readonly ?string $error,
|
||
/** @var array<string,mixed> служебное канала, ляжет в deliveries.meta */
|
||
public readonly array $meta,
|
||
) {}
|
||
|
||
public static function ok(array $meta = []): self
|
||
{
|
||
return new self(true, null, $meta);
|
||
}
|
||
|
||
public static function failed(string $error): self
|
||
{
|
||
return new self(false, $error, []);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Channels/ChannelDriver.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Channels;
|
||
|
||
use App\Enums\Presence;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
|
||
interface ChannelDriver
|
||
{
|
||
/** Пользователь сейчас доступен через этот канал? */
|
||
public function presence(User $user, array $config): Presence;
|
||
|
||
/** Доставить напоминание. Не бросает: ошибки — в DeliveryOutcome::failed. */
|
||
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome;
|
||
|
||
/** Событие закрыто: убрать кнопки и т.п. Best effort, никогда не бросает. */
|
||
public function onDone(Event $event, Delivery $delivery): void;
|
||
}
|
||
```
|
||
|
||
`core/app/Channels/ChannelRegistry.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Channels;
|
||
|
||
use App\Enums\ChannelType;
|
||
use Illuminate\Contracts\Container\Container;
|
||
|
||
final class ChannelRegistry
|
||
{
|
||
public function __construct(private readonly Container $container) {}
|
||
|
||
public function for(ChannelType $type): ChannelDriver
|
||
{
|
||
return match ($type) {
|
||
ChannelType::Web => $this->container->make(WebChannel::class),
|
||
ChannelType::Webhook => $this->container->make(WebhookChannel::class),
|
||
ChannelType::Telegram => $this->container->make(TelegramChannel::class),
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Channels/WebChannel.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Channels;
|
||
|
||
use App\Enums\Presence;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\Cache;
|
||
|
||
/** Инбокс. Ничего не шлёт (страница и так показывает всё), но присутствие определяет. */
|
||
final class WebChannel implements ChannelDriver
|
||
{
|
||
public function presence(User $user, array $config): Presence
|
||
{
|
||
return Cache::has("heartbeat:{$user->id}") ? Presence::Present : Presence::Absent;
|
||
}
|
||
|
||
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
|
||
{
|
||
return DeliveryOutcome::ok();
|
||
}
|
||
|
||
public function onDone(Event $event, Delivery $delivery): void {}
|
||
}
|
||
```
|
||
|
||
`core/app/Channels/WebhookChannel.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Channels;
|
||
|
||
use App\Enums\Presence;
|
||
use App\Http\Controllers\Api\EventsController as Presenter;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Throwable;
|
||
|
||
/** Универсальный webhook. В нашем случае — Home Assistant. */
|
||
final class WebhookChannel implements ChannelDriver
|
||
{
|
||
public function __construct(private readonly int $presenceTimeout) {}
|
||
|
||
public function presence(User $user, array $config): Presence
|
||
{
|
||
try {
|
||
$response = Http::timeout($this->presenceTimeout)->acceptJson()->get($config['presence_url']);
|
||
if (! $response->successful()) {
|
||
return Presence::Unknown;
|
||
}
|
||
|
||
return $response->json('present') === true ? Presence::Present : Presence::Absent;
|
||
} catch (Throwable) {
|
||
return Presence::Unknown;
|
||
}
|
||
}
|
||
|
||
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
|
||
{
|
||
try {
|
||
$response = Http::timeout(5)->asJson()->post($config['deliver_url'], self::payload($event, $delivery));
|
||
if ($response->successful()) {
|
||
return DeliveryOutcome::ok();
|
||
}
|
||
|
||
return DeliveryOutcome::failed("HTTP {$response->status()}: ".mb_substr($response->body(), 0, 200));
|
||
} catch (Throwable $e) {
|
||
return DeliveryOutcome::failed($e->getMessage());
|
||
}
|
||
}
|
||
|
||
public function onDone(Event $event, Delivery $delivery): void {}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function payload(Event $event, Delivery $delivery): array
|
||
{
|
||
$presented = Presenter::present($event);
|
||
|
||
return [
|
||
'event' => [
|
||
'id' => $event->id,
|
||
'state' => $presented['state'],
|
||
'due_at' => $presented['due_at'],
|
||
'source' => $event->source->name,
|
||
'title' => $event->payload['title'],
|
||
'subtitle' => $event->payload['subtitle'] ?? null,
|
||
'deep_link' => $event->payload['deep_link'] ?? null,
|
||
'done_label' => $event->payload['done_label'] ?? 'Сделано',
|
||
],
|
||
'actions' => [
|
||
'ack' => route('action.ack', $delivery->action_token),
|
||
'done' => route('action.done', $delivery->action_token),
|
||
],
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Channels/TelegramChannel.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Channels;
|
||
|
||
use App\Enums\Presence;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use Illuminate\Http\Client\Response;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Throwable;
|
||
|
||
final class TelegramChannel implements ChannelDriver
|
||
{
|
||
public function __construct(
|
||
private readonly string $botToken,
|
||
private readonly int $seenTtl,
|
||
) {}
|
||
|
||
public function presence(User $user, array $config): Presence
|
||
{
|
||
// Бот не видит онлайн-статус: present только если недавно жал кнопку или писал.
|
||
return Cache::has($this->seenKey($config['chat_id'])) ? Presence::Present : Presence::Unknown;
|
||
}
|
||
|
||
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
|
||
{
|
||
$text = $event->payload['title'];
|
||
if (! empty($event->payload['subtitle'])) {
|
||
$text .= "\n".$event->payload['subtitle'];
|
||
}
|
||
|
||
try {
|
||
$response = $this->call('sendMessage', [
|
||
'chat_id' => $config['chat_id'],
|
||
'text' => $text,
|
||
'reply_markup' => ['inline_keyboard' => [[
|
||
['text' => 'Помню', 'callback_data' => 'ack:'.$delivery->action_token],
|
||
['text' => $event->payload['done_label'] ?? 'Сделано', 'callback_data' => 'done:'.$delivery->action_token],
|
||
]]],
|
||
]);
|
||
} catch (Throwable $e) {
|
||
return DeliveryOutcome::failed($e->getMessage());
|
||
}
|
||
|
||
if (! $response->successful() || $response->json('ok') !== true) {
|
||
return DeliveryOutcome::failed('Telegram: '.($response->json('description') ?? "HTTP {$response->status()}"));
|
||
}
|
||
|
||
return DeliveryOutcome::ok(['message_id' => $response->json('result.message_id')]);
|
||
}
|
||
|
||
public function onDone(Event $event, Delivery $delivery): void
|
||
{
|
||
$messageId = $delivery->meta['message_id'] ?? null;
|
||
$chatId = $delivery->channel?->config['chat_id'] ?? null;
|
||
if ($messageId === null || $chatId === null) {
|
||
return;
|
||
}
|
||
try {
|
||
$this->call('editMessageReplyMarkup', ['chat_id' => $chatId, 'message_id' => $messageId, 'reply_markup' => ['inline_keyboard' => []]]);
|
||
} catch (Throwable) {
|
||
// best effort
|
||
}
|
||
}
|
||
|
||
public function touch(int|string $chatId): void
|
||
{
|
||
Cache::put($this->seenKey($chatId), true, $this->seenTtl);
|
||
}
|
||
|
||
public function sendText(int|string $chatId, string $text): void
|
||
{
|
||
try {
|
||
$this->call('sendMessage', ['chat_id' => $chatId, 'text' => $text]);
|
||
} catch (Throwable) {
|
||
// best effort
|
||
}
|
||
}
|
||
|
||
public function answerCallback(string $callbackId, string $text): void
|
||
{
|
||
try {
|
||
$this->call('answerCallbackQuery', ['callback_query_id' => $callbackId, 'text' => $text]);
|
||
} catch (Throwable) {
|
||
// best effort
|
||
}
|
||
}
|
||
|
||
private function call(string $method, array $params): Response
|
||
{
|
||
return Http::timeout(5)->asJson()->post("https://api.telegram.org/bot{$this->botToken}/{$method}", $params);
|
||
}
|
||
|
||
private function seenKey(int|string $chatId): string
|
||
{
|
||
return "tg:seen:{$chatId}";
|
||
}
|
||
}
|
||
```
|
||
|
||
Bindings in `core/app/Providers/AppServiceProvider.php` `register()` (next to the `WindowResolver` binding):
|
||
```php
|
||
$this->app->singleton(\App\Channels\WebhookChannel::class, fn () => new \App\Channels\WebhookChannel(config('hado.presence_timeout')));
|
||
$this->app->singleton(\App\Channels\TelegramChannel::class, fn () => new \App\Channels\TelegramChannel(
|
||
config('hado.telegram.token'),
|
||
config('hado.telegram.seen_ttl'),
|
||
));
|
||
```
|
||
|
||
Now hook `onDone` into `EventActions::done` (`core/app/Delivery/EventActions.php`): add `ChannelRegistry $channels` to the constructor and after `$event->save()` in `done()`:
|
||
```php
|
||
foreach ($event->deliveries()->with('channel')->get() as $delivery) {
|
||
$this->channels->for($delivery->channel->type)->onDone($event, $delivery);
|
||
}
|
||
```
|
||
with `use App\Channels\ChannelRegistry;`.
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "WebChannelTest|WebhookChannelTest|TelegramChannelTest|MeApiTest"
|
||
```
|
||
Expected: all PASS (MeApiTest still green after the `EventActions` constructor change).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web, webhook and telegram channel drivers
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Tick, DeliverJob, scheduler, purge
|
||
|
||
**Files:**
|
||
- Create: `core/app/Delivery/Tick.php`, `core/app/Delivery/DeliverJob.php`, `core/app/Delivery/DeliveryFailed.php`, `core/app/Console/Commands/TickCommand.php`, `core/app/Console/Commands/PurgeCommand.php`
|
||
- Modify: `core/routes/console.php` (schedule)
|
||
- Test: `core/tests/Feature/TickTest.php`, `core/tests/Feature/DeliverJobTest.php`, `core/tests/Feature/PurgeCommandTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `StateResolver`, `DueResolver`, `WindowResolver`, `ChannelRegistry`, models.
|
||
- Produces:
|
||
- `Tick::run(): void` — one pass over all non-terminal events.
|
||
- `DeliverJob::dispatch(int $deliveryId)`; `$tries = 3`, `backoff() = [30, 120, 600]`; marks `ok`/`failed`.
|
||
- artisan `hado:tick` (every minute), `hado:purge` (daily at 04:00).
|
||
- Presence cache key `presence:{channel_id}`, TTL `hado.presence_ttl`.
|
||
- Fake driver for tests: `Tests\Support\FakeChannel` (in `core/tests/Support/FakeChannel.php`), bound over `ChannelRegistry` via a `Tests\Support\FakeRegistry`.
|
||
|
||
- [ ] **Step 1: Test support classes**
|
||
|
||
`core/tests/Support/FakeChannel.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Support;
|
||
|
||
use App\Channels\ChannelDriver;
|
||
use App\Channels\DeliveryOutcome;
|
||
use App\Enums\Presence;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
|
||
final class FakeChannel implements ChannelDriver
|
||
{
|
||
/** @var list<int> event ids delivered */
|
||
public array $delivered = [];
|
||
|
||
public function __construct(public Presence $presence = Presence::Unknown, public bool $succeed = true) {}
|
||
|
||
public function presence(User $user, array $config): Presence
|
||
{
|
||
return $this->presence;
|
||
}
|
||
|
||
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
|
||
{
|
||
$this->delivered[] = $event->id;
|
||
|
||
return $this->succeed ? DeliveryOutcome::ok(['n' => count($this->delivered)]) : DeliveryOutcome::failed('boom');
|
||
}
|
||
|
||
public function onDone(Event $event, Delivery $delivery): void {}
|
||
}
|
||
```
|
||
|
||
`core/tests/Support/FakeRegistry.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Support;
|
||
|
||
use App\Channels\ChannelDriver;
|
||
use App\Channels\ChannelRegistry;
|
||
use App\Enums\ChannelType;
|
||
use Illuminate\Container\Container;
|
||
|
||
final class FakeRegistry extends ChannelRegistry
|
||
{
|
||
/** @param array<string, ChannelDriver> $drivers keyed by ChannelType value */
|
||
public function __construct(private readonly array $drivers)
|
||
{
|
||
parent::__construct(Container::getInstance());
|
||
}
|
||
|
||
public function for(ChannelType $type): ChannelDriver
|
||
{
|
||
return $this->drivers[$type->value];
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `"Tests\\Support\\": "tests/Support/"` is unnecessary: Laravel's `composer.json` autoloads `Tests\\` from `tests/` already.
|
||
|
||
- [ ] **Step 2: Failing tests**
|
||
|
||
`core/tests/Feature/TickTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Channels\ChannelRegistry;
|
||
use App\Delivery\Tick;
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Enums\Presence;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Tests\Support\FakeChannel;
|
||
use Tests\Support\FakeRegistry;
|
||
use Tests\TestCase;
|
||
|
||
class TickTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private User $user;
|
||
private Source $source;
|
||
private FakeChannel $web;
|
||
private FakeChannel $telegram;
|
||
private FakeChannel $webhook;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$this->user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Web, 'config' => [], 'enabled' => true]);
|
||
Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Telegram, 'config' => ['chat_id' => 1], 'enabled' => true]);
|
||
Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
|
||
|
||
$this->web = new FakeChannel(Presence::Absent);
|
||
$this->telegram = new FakeChannel(Presence::Unknown);
|
||
$this->webhook = new FakeChannel(Presence::Absent);
|
||
$this->app->instance(ChannelRegistry::class, new FakeRegistry([
|
||
'web' => $this->web, 'telegram' => $this->telegram, 'webhook' => $this->webhook,
|
||
]));
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
private function event(string $due, string $fireOn, AfterDue $afterDue = AfterDue::Keep, string $time = '24:00:00'): Event
|
||
{
|
||
return Event::create([
|
||
'source_id' => $this->source->id, 'user_id' => $this->user->id, 'source_ref' => 'r', 'topic' => uniqid(),
|
||
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn,
|
||
'after_due' => $afterDue, 'payload' => ['title' => 'T'], 'state' => EventState::Scheduled,
|
||
'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]);
|
||
}
|
||
|
||
private function tickAt(string $utc): void
|
||
{
|
||
CarbonImmutable::setTestNow($utc);
|
||
Cache::flush();
|
||
app(Tick::class)->run();
|
||
}
|
||
|
||
public function test_moves_states_without_delivering_before_windows(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-08-31');
|
||
|
||
$this->tickAt('2026-08-31T03:00:00Z');
|
||
$this->assertSame(EventState::Preparing, $e->fresh()->state);
|
||
$this->assertSame(0, Delivery::count(), 'тихие часы — доставок нет');
|
||
|
||
$this->tickAt('2026-10-01T00:00:00Z');
|
||
$this->assertSame(EventState::Overdue, $e->fresh()->state);
|
||
}
|
||
|
||
public function test_delivers_only_to_present_channels_and_once_per_window(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-08-31');
|
||
$this->webhook->presence = Presence::Present;
|
||
|
||
$this->tickAt('2026-09-05T10:00:00Z');
|
||
$deliveries = Delivery::all();
|
||
$this->assertCount(1, $deliveries);
|
||
$this->assertSame(ChannelType::Webhook, $deliveries->sole()->channel->type);
|
||
$this->assertSame(DeliveryResult::Ok, $deliveries->sole()->result, 'sync-очередь: job уже отработал');
|
||
$this->assertSame('2026-09-05T09:00:00+00:00', $deliveries->sole()->window_start->toIso8601String());
|
||
|
||
$this->tickAt('2026-09-05T15:00:00Z');
|
||
$this->assertSame(1, Delivery::count(), 'второй раз в то же окно не шлём');
|
||
|
||
$this->tickAt('2026-09-06T10:00:00Z');
|
||
$this->assertSame(2, Delivery::count(), 'новое окно — новая доставка');
|
||
}
|
||
|
||
public function test_falls_back_to_all_channels_near_window_end(): void
|
||
{
|
||
$this->event('2026-09-30', '2026-08-31');
|
||
|
||
$this->tickAt('2026-09-05T21:00:00Z');
|
||
$this->assertSame(0, Delivery::count(), 'никто не present, до fallback ещё далеко');
|
||
|
||
$this->tickAt('2026-09-05T21:31:00Z');
|
||
$this->assertSame(3, Delivery::count(), 'fallback во все три канала');
|
||
$this->assertSame([1, 1, 1], [count($this->web->delivered), count($this->telegram->delivered), count($this->webhook->delivered)]);
|
||
}
|
||
|
||
public function test_today_fallback_limited_to_listed_hours(): void
|
||
{
|
||
$this->event('2026-09-30', '2026-09-30');
|
||
|
||
$this->tickAt('2026-09-30T11:55:00Z');
|
||
$this->assertSame(0, Delivery::count(), '11:00 не fallback-час');
|
||
|
||
$this->tickAt('2026-09-30T14:55:00Z');
|
||
$this->assertSame(3, Delivery::count(), '14:00 — fallback-час');
|
||
}
|
||
|
||
public function test_today_hourly_when_present(): void
|
||
{
|
||
$this->event('2026-09-30', '2026-09-30');
|
||
$this->web->presence = Presence::Present;
|
||
|
||
$this->tickAt('2026-09-30T11:05:00Z');
|
||
$this->tickAt('2026-09-30T11:45:00Z');
|
||
$this->tickAt('2026-09-30T12:05:00Z');
|
||
$this->assertSame(2, Delivery::count());
|
||
$this->assertSame(0, count($this->telegram->delivered), 'в telegram не шли: web present');
|
||
}
|
||
|
||
public function test_quiet_until_blocks_delivery(): void
|
||
{
|
||
$e = $this->event('2026-09-30', '2026-08-31');
|
||
$e->update(['quiet_until' => CarbonImmutable::parse('2026-09-15T00:00:00Z')]);
|
||
$this->webhook->presence = Presence::Present;
|
||
|
||
$this->tickAt('2026-09-10T10:00:00Z');
|
||
$this->assertSame(0, Delivery::count());
|
||
|
||
$this->tickAt('2026-09-15T10:00:00Z');
|
||
$this->assertSame(1, Delivery::count());
|
||
}
|
||
|
||
public function test_disabled_channel_is_skipped_and_user_without_channels_is_fine(): void
|
||
{
|
||
Channel::where('type', ChannelType::Webhook->value)->update(['enabled' => false]);
|
||
$this->webhook->presence = Presence::Present;
|
||
$this->event('2026-09-30', '2026-08-31');
|
||
|
||
$this->tickAt('2026-09-05T21:31:00Z');
|
||
$this->assertSame(2, Delivery::count(), 'fallback только в включённые');
|
||
|
||
Channel::query()->delete();
|
||
$this->tickAt('2026-09-06T21:31:00Z');
|
||
$this->assertSame(2, Delivery::count());
|
||
}
|
||
|
||
public function test_user_timezone_shifts_windows(): void
|
||
{
|
||
$this->user->update(['tz' => 'Asia/Shanghai']); // UTC+8
|
||
$this->event('2026-09-30', '2026-08-31');
|
||
$this->webhook->presence = Presence::Present;
|
||
|
||
$this->tickAt('2026-09-05T00:30:00Z'); // 08:30 в Шанхае — тихие часы
|
||
$this->assertSame(0, Delivery::count());
|
||
$this->tickAt('2026-09-05T01:30:00Z'); // 09:30 в Шанхае
|
||
$this->assertSame(1, Delivery::count());
|
||
}
|
||
|
||
public function test_presence_is_cached_for_a_minute(): void
|
||
{
|
||
$this->event('2026-09-30', '2026-08-31');
|
||
$this->webhook->presence = Presence::Present;
|
||
|
||
CarbonImmutable::setTestNow('2026-09-05T10:00:00Z');
|
||
app(Tick::class)->run();
|
||
$this->webhook->presence = Presence::Absent;
|
||
$this->assertSame(Presence::Present, Cache::get('presence:'.Channel::where('type', 'webhook')->sole()->id));
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Feature/DeliverJobTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Channels\ChannelRegistry;
|
||
use App\Delivery\DeliverJob;
|
||
use App\Delivery\DeliveryFailed;
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\Support\FakeChannel;
|
||
use Tests\Support\FakeRegistry;
|
||
use Tests\TestCase;
|
||
|
||
class DeliverJobTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private Delivery $delivery;
|
||
private FakeChannel $driver;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$channel = Channel::create(['user_id' => $user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
|
||
$event = Event::create([
|
||
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => '',
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-08-31',
|
||
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Preparing,
|
||
'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]);
|
||
$this->delivery = Delivery::create([
|
||
'event_id' => $event->id, 'channel_id' => $channel->id, 'window_start' => CarbonImmutable::parse('2026-09-05T09:00:00Z'),
|
||
'action_token' => 'tok', 'result' => DeliveryResult::Pending,
|
||
]);
|
||
$this->driver = new FakeChannel;
|
||
$this->app->instance(ChannelRegistry::class, new FakeRegistry(['webhook' => $this->driver]));
|
||
}
|
||
|
||
public function test_success_marks_ok_with_meta(): void
|
||
{
|
||
(new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
|
||
|
||
$d = $this->delivery->fresh();
|
||
$this->assertSame(DeliveryResult::Ok, $d->result);
|
||
$this->assertNotNull($d->sent_at);
|
||
$this->assertSame(['n' => 1], $d->meta);
|
||
}
|
||
|
||
public function test_failure_records_error_and_throws_for_retry(): void
|
||
{
|
||
$this->driver->succeed = false;
|
||
|
||
$this->expectException(DeliveryFailed::class);
|
||
try {
|
||
(new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
|
||
} finally {
|
||
$d = $this->delivery->fresh();
|
||
$this->assertSame(DeliveryResult::Pending, $d->result, 'ещё pending — будут ретраи');
|
||
$this->assertSame('boom', $d->error);
|
||
}
|
||
}
|
||
|
||
public function test_failed_hook_marks_failed(): void
|
||
{
|
||
(new DeliverJob($this->delivery->id))->failed(new DeliveryFailed('boom'));
|
||
$this->assertSame(DeliveryResult::Failed, $this->delivery->fresh()->result);
|
||
}
|
||
|
||
public function test_skips_when_already_done_or_event_terminal(): void
|
||
{
|
||
$this->delivery->update(['result' => DeliveryResult::Ok]);
|
||
(new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
|
||
$this->assertSame([], $this->driver->delivered);
|
||
|
||
$this->delivery->update(['result' => DeliveryResult::Pending]);
|
||
$this->delivery->event->update(['state' => EventState::Done]);
|
||
(new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
|
||
$this->assertSame([], $this->driver->delivered);
|
||
$this->assertSame(DeliveryResult::Failed, $this->delivery->fresh()->result);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Feature/PurgeCommandTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class PurgeCommandTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
public function test_deletes_old_terminal_events_only(): void
|
||
{
|
||
config(['hado.retention_days' => 90]);
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$user = User::create(['login' => 'n', 'tz' => 'UTC']);
|
||
$make = fn (string $topic, EventState $state, string $updatedAt) => tap(Event::create([
|
||
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-01-01', 'due_time' => '24:00:00', 'fire_on' => '2026-01-01',
|
||
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => $state,
|
||
'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]), fn (Event $e) => Event::whereKey($e->id)->update(['updated_at' => $updatedAt]));
|
||
|
||
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
|
||
$make('old-done', EventState::Done, '2026-05-01 00:00:00');
|
||
$make('old-expired', EventState::Expired, '2026-05-01 00:00:00');
|
||
$make('fresh-done', EventState::Done, '2026-08-01 00:00:00');
|
||
$make('old-live', EventState::Overdue, '2026-05-01 00:00:00');
|
||
|
||
$this->artisan('hado:purge')->assertSuccessful();
|
||
|
||
$this->assertSame(['fresh-done', 'old-live'], Event::orderBy('topic')->pluck('topic')->all());
|
||
CarbonImmutable::setTestNow();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "TickTest|DeliverJobTest|PurgeCommandTest"
|
||
```
|
||
Expected: FAIL, classes not found.
|
||
|
||
- [ ] **Step 4: Implement**
|
||
|
||
`core/app/Delivery/DeliveryFailed.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Delivery;
|
||
|
||
use RuntimeException;
|
||
|
||
final class DeliveryFailed extends RuntimeException {}
|
||
```
|
||
|
||
`core/app/Delivery/DeliverJob.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Delivery;
|
||
|
||
use App\Channels\ChannelRegistry;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Models\Delivery;
|
||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||
use Illuminate\Foundation\Queue\Queueable;
|
||
use Throwable;
|
||
|
||
final class DeliverJob implements ShouldQueue
|
||
{
|
||
use Queueable;
|
||
|
||
public int $tries = 3;
|
||
|
||
public function __construct(public readonly int $deliveryId) {}
|
||
|
||
/** @return list<int> */
|
||
public function backoff(): array
|
||
{
|
||
return [30, 120, 600];
|
||
}
|
||
|
||
public function handle(ChannelRegistry $registry): void
|
||
{
|
||
$delivery = Delivery::with(['event.user', 'event.source', 'channel'])->findOrFail($this->deliveryId);
|
||
if ($delivery->result !== DeliveryResult::Pending) {
|
||
return;
|
||
}
|
||
|
||
if ($delivery->event->isTerminal()) {
|
||
$delivery->update(['result' => DeliveryResult::Failed, 'error' => 'skipped: событие уже закрыто']);
|
||
|
||
return;
|
||
}
|
||
|
||
$outcome = $registry->for($delivery->channel->type)
|
||
->deliver($delivery->event, $delivery->event->user, $delivery->channel->config, $delivery);
|
||
|
||
if ($outcome->ok) {
|
||
$delivery->update(['result' => DeliveryResult::Ok, 'sent_at' => now(), 'error' => null, 'meta' => $outcome->meta]);
|
||
|
||
return;
|
||
}
|
||
|
||
$delivery->update(['error' => $outcome->error]);
|
||
throw new DeliveryFailed($outcome->error ?? 'delivery failed');
|
||
}
|
||
|
||
public function failed(Throwable $e): void
|
||
{
|
||
Delivery::whereKey($this->deliveryId)
|
||
->where('result', DeliveryResult::Pending->value)
|
||
->update(['result' => DeliveryResult::Failed, 'error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Delivery/Tick.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Delivery;
|
||
|
||
use App\Channels\ChannelRegistry;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Enums\EventState;
|
||
use App\Enums\Presence;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use App\Scheduling\DueResolver;
|
||
use App\Scheduling\StateResolver;
|
||
use App\Scheduling\Window;
|
||
use App\Scheduling\WindowResolver;
|
||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Один тик планировщика (раз в минуту): переходы состояний по датам и решение,
|
||
* доставлять ли напоминание в открытое окно и куда.
|
||
*/
|
||
final class Tick
|
||
{
|
||
public function __construct(
|
||
private readonly DueResolver $due,
|
||
private readonly StateResolver $states,
|
||
private readonly WindowResolver $windows,
|
||
private readonly ChannelRegistry $registry,
|
||
private readonly Cache $cache,
|
||
private readonly int $presenceTtl,
|
||
) {}
|
||
|
||
public function run(): void
|
||
{
|
||
Event::query()
|
||
->whereIn('state', EventState::nonTerminalValues())
|
||
->with('user')
|
||
->chunkById(200, fn (Collection $events) => $events->each(fn (Event $e) => $this->process($e)));
|
||
}
|
||
|
||
private function process(Event $event): void
|
||
{
|
||
$user = $event->user;
|
||
$now = $user->now();
|
||
|
||
$state = $this->states->resolve($event, $user, $now);
|
||
if ($state !== $event->state) {
|
||
$event->state = $state;
|
||
$event->save();
|
||
}
|
||
if (! $state->isActive() || $now->lt($event->quiet_until)) {
|
||
return;
|
||
}
|
||
|
||
$window = $this->windows->current($state, $user, $this->due->resolve($event, $user), $now);
|
||
if ($window === null) {
|
||
return;
|
||
}
|
||
if (Delivery::where('event_id', $event->id)->where('window_start', $window->start->utc())->exists()) {
|
||
return;
|
||
}
|
||
|
||
$channels = $user->channels()->where('enabled', true)->get();
|
||
if ($channels->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$present = $channels->filter(fn (Channel $c) => $this->presence($c, $user) === Presence::Present);
|
||
if ($present->isNotEmpty()) {
|
||
$this->dispatch($event, $present, $window);
|
||
|
||
return;
|
||
}
|
||
if ($window->fallbackAllowed && $now->gte($window->fallbackAt)) {
|
||
$this->dispatch($event, $channels, $window);
|
||
}
|
||
}
|
||
|
||
private function presence(Channel $channel, User $user): Presence
|
||
{
|
||
return $this->cache->remember(
|
||
"presence:{$channel->id}",
|
||
$this->presenceTtl,
|
||
fn () => $this->registry->for($channel->type)->presence($user, $channel->config),
|
||
);
|
||
}
|
||
|
||
/** Строка pending — в транзакции ДО постановки job-а: следующий тик её уже видит. */
|
||
private function dispatch(Event $event, Collection $channels, Window $window): void
|
||
{
|
||
DB::transaction(function () use ($event, $channels, $window) {
|
||
foreach ($channels as $channel) {
|
||
$delivery = Delivery::create([
|
||
'event_id' => $event->id,
|
||
'channel_id' => $channel->id,
|
||
'window_start' => $window->start->utc(),
|
||
'action_token' => Str::random(48),
|
||
'result' => DeliveryResult::Pending,
|
||
]);
|
||
DeliverJob::dispatch($delivery->id)->afterCommit();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
Bind in `AppServiceProvider::register()`:
|
||
```php
|
||
$this->app->bind(\App\Delivery\Tick::class, fn ($app) => new \App\Delivery\Tick(
|
||
$app->make(\App\Scheduling\DueResolver::class),
|
||
$app->make(\App\Scheduling\StateResolver::class),
|
||
$app->make(\App\Scheduling\WindowResolver::class),
|
||
$app->make(\App\Channels\ChannelRegistry::class),
|
||
$app->make('cache.store'),
|
||
config('hado.presence_ttl'),
|
||
));
|
||
```
|
||
|
||
`core/app/Console/Commands/TickCommand.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Delivery\Tick;
|
||
use Illuminate\Console\Command;
|
||
|
||
class TickCommand extends Command
|
||
{
|
||
protected $signature = 'hado:tick';
|
||
|
||
protected $description = 'Один тик: переходы состояний и доставка напоминаний в открытые окна';
|
||
|
||
public function handle(Tick $tick): int
|
||
{
|
||
$tick->run();
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Console/Commands/PurgeCommand.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Console\Command;
|
||
|
||
class PurgeCommand extends Command
|
||
{
|
||
protected $signature = 'hado:purge';
|
||
|
||
protected $description = 'Удаляет терминальные события старше HADO_RETENTION_DAYS';
|
||
|
||
public function handle(): int
|
||
{
|
||
$terminal = array_map(fn (EventState $s) => $s->value, array_filter(EventState::cases(), fn (EventState $s) => $s->isTerminal()));
|
||
$cutoff = CarbonImmutable::now()->subDays((int) config('hado.retention_days'));
|
||
|
||
$deleted = Event::whereIn('state', $terminal)->where('updated_at', '<', $cutoff)->delete();
|
||
$this->info("Удалено событий: {$deleted}");
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/routes/console.php` (replace content):
|
||
```php
|
||
<?php
|
||
|
||
use Illuminate\Support\Facades\Schedule;
|
||
|
||
Schedule::command('hado:tick')->everyMinute()->withoutOverlapping()->runInBackground();
|
||
Schedule::command('hado:purge')->dailyAt('04:00');
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "TickTest|DeliverJobTest|PurgeCommandTest"
|
||
```
|
||
Expected: 14 tests PASS. Notes: `QUEUE_CONNECTION=sync` in tests makes `afterCommit()` run the job right after the transaction, which `test_delivers_only_to_present_channels_and_once_per_window` relies on. If `window_start` comparison misses on SQLite, compare with `->where('window_start', $window->start->utc()->format('Y-m-d H:i:s'))` in both `Tick::process` and the `Delivery::create` payload.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: tick planner, delivery job, scheduler and purge
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Action links, Telegram webhook, channels API
|
||
|
||
**Files:**
|
||
- Create: `core/app/Http/Controllers/ActionController.php`, `core/app/Http/Controllers/TelegramWebhookController.php`, `core/app/Http/Controllers/Me/ChannelsController.php`
|
||
- Modify: `core/routes/web.php`
|
||
- Test: `core/tests/Feature/ActionLinksTest.php`, `core/tests/Feature/TelegramWebhookTest.php`, `core/tests/Feature/ChannelsApiTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EventActions`, `TelegramChannel::touch/sendText/answerCallback`, `Delivery.action_token`.
|
||
- Produces:
|
||
- `POST /a/{token}/ack` → `action.ack`, `POST /a/{token}/done` → `action.done`. 404 unknown token, 410 if the event is terminal, 200 `{state}` otherwise. No CSRF, no SSO.
|
||
- `POST /hooks/telegram` — checks header `X-Telegram-Bot-Api-Secret-Token` against `hado.telegram.webhook_secret` (403 otherwise). Handles `/start <code>` linking and `callback_query` with `ack:{token}` / `done:{token}`.
|
||
- `GET /me/channels`, `POST /me/channels` `{type: webhook, config: {deliver_url, presence_url}}`, `DELETE /me/channels/{id}` (web channel is not deletable → 422), `POST /me/channels/telegram/link` → `{code, bot_url}`. Link code cache key `tg:link:{code}` → user id, TTL 15 min.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`core/tests/Feature/ActionLinksTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class ActionLinksTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private Event $event;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
CarbonImmutable::setTestNow('2026-09-30T11:17:00Z');
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
$channel = Channel::create(['user_id' => $user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
|
||
$this->event = Event::create([
|
||
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => '',
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-09-30',
|
||
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Today,
|
||
'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]);
|
||
Delivery::create(['event_id' => $this->event->id, 'channel_id' => $channel->id, 'window_start' => now(), 'action_token' => 'tok', 'result' => DeliveryResult::Ok]);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function test_ack_by_token(): void
|
||
{
|
||
$this->postJson('/a/tok/ack')->assertOk()->assertJsonPath('state', 'today');
|
||
$this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String());
|
||
}
|
||
|
||
public function test_done_by_token_then_gone(): void
|
||
{
|
||
$this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done');
|
||
$this->assertSame(EventState::Done, $this->event->fresh()->state);
|
||
|
||
$this->postJson('/a/tok/done')->assertStatus(410);
|
||
$this->postJson('/a/tok/ack')->assertStatus(410);
|
||
}
|
||
|
||
public function test_unknown_token(): void
|
||
{
|
||
$this->postJson('/a/nope/done')->assertNotFound();
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Feature/TelegramWebhookTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\ChannelType;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Http\Client\Request;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Tests\TestCase;
|
||
|
||
class TelegramWebhookTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private User $user;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => []])]);
|
||
$this->user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
|
||
}
|
||
|
||
private function secret(): array
|
||
{
|
||
return ['X-Telegram-Bot-Api-Secret-Token' => 'test-secret'];
|
||
}
|
||
|
||
public function test_rejects_wrong_secret(): void
|
||
{
|
||
$this->postJson('/hooks/telegram', ['message' => []])->assertStatus(403);
|
||
$this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403);
|
||
}
|
||
|
||
public function test_start_with_code_links_chat(): void
|
||
{
|
||
Cache::put('tg:link:ABC123', $this->user->id, 900);
|
||
|
||
$this->postJson('/hooks/telegram', ['message' => ['chat' => ['id' => 555], 'text' => '/start ABC123']], $this->secret())->assertNoContent();
|
||
|
||
$channel = $this->user->channels()->where('type', ChannelType::Telegram->value)->sole();
|
||
$this->assertSame(555, $channel->config['chat_id']);
|
||
$this->assertFalse(Cache::has('tg:link:ABC123'), 'код одноразовый');
|
||
$this->assertTrue(Cache::has('tg:seen:555'), 'взаимодействие = присутствие');
|
||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/sendMessage') && $r['text'] === 'Подключено');
|
||
}
|
||
|
||
public function test_start_with_bad_code_replies_error(): void
|
||
{
|
||
$this->postJson('/hooks/telegram', ['message' => ['chat' => ['id' => 555], 'text' => '/start NOPE']], $this->secret())->assertNoContent();
|
||
$this->assertSame(0, Channel::count());
|
||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/sendMessage') && str_contains($r['text'], 'Код'));
|
||
}
|
||
|
||
public function test_callback_ack_and_done(): void
|
||
{
|
||
CarbonImmutable::setTestNow('2026-09-30T11:17:00Z');
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$channel = Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Telegram, 'config' => ['chat_id' => 555], 'enabled' => true]);
|
||
$event = Event::create([
|
||
'source_id' => $source->id, 'user_id' => $this->user->id, 'source_ref' => 'r', 'topic' => '',
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-09-30',
|
||
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Today,
|
||
'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]);
|
||
Delivery::create(['event_id' => $event->id, 'channel_id' => $channel->id, 'window_start' => now(), 'action_token' => 'tok', 'result' => DeliveryResult::Ok, 'meta' => ['message_id' => 9001]]);
|
||
|
||
$cb = fn (string $data) => ['callback_query' => ['id' => 'cb1', 'data' => $data, 'message' => ['chat' => ['id' => 555], 'message_id' => 9001]]];
|
||
|
||
$this->postJson('/hooks/telegram', $cb('ack:tok'), $this->secret())->assertNoContent();
|
||
$this->assertSame('2026-09-30T13:00:00+00:00', $event->fresh()->quiet_until->toIso8601String());
|
||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/answerCallbackQuery') && $r['callback_query_id'] === 'cb1');
|
||
|
||
$this->postJson('/hooks/telegram', $cb('done:tok'), $this->secret())->assertNoContent();
|
||
$this->assertSame(EventState::Done, $event->fresh()->state);
|
||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001);
|
||
|
||
$this->postJson('/hooks/telegram', $cb('done:tok'), $this->secret())->assertNoContent();
|
||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/answerCallbackQuery') && $r['text'] === 'Уже неактуально');
|
||
|
||
CarbonImmutable::setTestNow();
|
||
}
|
||
|
||
public function test_unrelated_update_is_ignored(): void
|
||
{
|
||
$this->postJson('/hooks/telegram', ['edited_message' => ['chat' => ['id' => 1]]], $this->secret())->assertNoContent();
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/tests/Feature/ChannelsApiTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\ChannelType;
|
||
use App\Models\Channel;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Tests\TestCase;
|
||
|
||
class ChannelsApiTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private array $as = ['X-Remote-User' => 'nikita'];
|
||
|
||
public function test_lists_channels_including_auto_web(): void
|
||
{
|
||
$this->getJson('/me/channels', $this->as)->assertOk()
|
||
->assertJsonCount(1, 'channels')
|
||
->assertJsonPath('channels.0.type', 'web');
|
||
}
|
||
|
||
public function test_adds_and_deletes_webhook_channel(): void
|
||
{
|
||
$r = $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/presence']], $this->as)
|
||
->assertCreated()->assertJsonPath('type', 'webhook');
|
||
|
||
$this->deleteJson('/me/channels/'.$r->json('id'), [], $this->as)->assertNoContent();
|
||
$this->assertSame(1, Channel::count());
|
||
}
|
||
|
||
public function test_validates_webhook_config(): void
|
||
{
|
||
$this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'not a url']], $this->as)
|
||
->assertStatus(422)->assertJsonValidationErrors(['config.deliver_url', 'config.presence_url']);
|
||
// telegram подключается только через /start, не через API
|
||
$this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as)
|
||
->assertStatus(422);
|
||
}
|
||
|
||
public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void
|
||
{
|
||
$this->getJson('/me', $this->as);
|
||
$web = User::sole()->channels()->sole();
|
||
$this->deleteJson('/me/channels/'.$web->id, [], $this->as)->assertStatus(422);
|
||
|
||
$this->getJson('/me', ['X-Remote-User' => 'other']);
|
||
$foreign = Channel::create(['user_id' => User::where('login', 'other')->sole()->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
|
||
$this->deleteJson('/me/channels/'.$foreign->id, [], $this->as)->assertNotFound();
|
||
}
|
||
|
||
public function test_telegram_link_code(): void
|
||
{
|
||
$r = $this->postJson('/me/channels/telegram/link', [], $this->as)->assertOk();
|
||
$code = $r->json('code');
|
||
|
||
$this->assertSame(6, strlen($code));
|
||
$this->assertSame('https://t.me/hado_test_bot?start='.$code, $r->json('bot_url'));
|
||
$this->assertSame(User::sole()->id, Cache::get("tg:link:$code"));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "ActionLinksTest|TelegramWebhookTest|ChannelsApiTest"
|
||
```
|
||
Expected: FAIL (404s).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`core/app/Http/Controllers/ActionController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Delivery\EventActions;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use Illuminate\Http\JsonResponse;
|
||
|
||
/** Кнопки из каналов: подписанные ссылки по action_token доставки. Без SSO и CSRF. */
|
||
class ActionController extends Controller
|
||
{
|
||
public function __construct(private readonly EventActions $actions) {}
|
||
|
||
public function ack(string $token): JsonResponse
|
||
{
|
||
$event = $this->eventFor($token);
|
||
$this->actions->ack($event);
|
||
|
||
return response()->json(['state' => $event->fresh()->state->value]);
|
||
}
|
||
|
||
public function done(string $token): JsonResponse
|
||
{
|
||
$event = $this->eventFor($token);
|
||
$this->actions->done($event);
|
||
|
||
return response()->json(['state' => $event->fresh()->state->value]);
|
||
}
|
||
|
||
private function eventFor(string $token): Event
|
||
{
|
||
$delivery = Delivery::where('action_token', $token)->with('event.user')->first();
|
||
abort_if($delivery === null, 404, 'Неизвестный токен');
|
||
abort_if($delivery->event->isTerminal(), 410, 'Событие уже закрыто');
|
||
|
||
return $delivery->event;
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Http/Controllers/TelegramWebhookController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Channels\TelegramChannel;
|
||
use App\Delivery\EventActions;
|
||
use App\Enums\ChannelType;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Facades\Cache;
|
||
|
||
class TelegramWebhookController extends Controller
|
||
{
|
||
public function __construct(
|
||
private readonly TelegramChannel $telegram,
|
||
private readonly EventActions $actions,
|
||
) {}
|
||
|
||
public function __invoke(Request $request): Response
|
||
{
|
||
$expected = (string) config('hado.telegram.webhook_secret');
|
||
$given = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
|
||
abort_unless($expected !== '' && hash_equals($expected, $given), 403);
|
||
|
||
if (is_array($message = $request->input('message'))) {
|
||
$this->message($message);
|
||
}
|
||
if (is_array($callback = $request->input('callback_query'))) {
|
||
$this->callback($callback);
|
||
}
|
||
|
||
return response()->noContent();
|
||
}
|
||
|
||
private function message(array $message): void
|
||
{
|
||
$chatId = $message['chat']['id'] ?? null;
|
||
if ($chatId === null) {
|
||
return;
|
||
}
|
||
$this->telegram->touch($chatId);
|
||
|
||
if (! preg_match('~^/start\s+(\S+)~', (string) ($message['text'] ?? ''), $m)) {
|
||
return;
|
||
}
|
||
|
||
$userId = Cache::pull('tg:link:'.$m[1]);
|
||
if ($userId === null) {
|
||
$this->telegram->sendText($chatId, 'Код не найден или устарел. Сгенерируй новый в инбоксе Hado.');
|
||
|
||
return;
|
||
}
|
||
|
||
Channel::updateOrCreate(
|
||
['user_id' => $userId, 'type' => ChannelType::Telegram->value],
|
||
['config' => ['chat_id' => $chatId], 'enabled' => true],
|
||
);
|
||
$this->telegram->sendText($chatId, 'Подключено');
|
||
}
|
||
|
||
private function callback(array $callback): void
|
||
{
|
||
$chatId = $callback['message']['chat']['id'] ?? null;
|
||
if ($chatId !== null) {
|
||
$this->telegram->touch($chatId);
|
||
}
|
||
|
||
[$action, $token] = array_pad(explode(':', (string) ($callback['data'] ?? ''), 2), 2, '');
|
||
$delivery = Delivery::where('action_token', $token)->with('event.user')->first();
|
||
|
||
if ($delivery === null || $delivery->event->isTerminal() || ! in_array($action, ['ack', 'done'], true)) {
|
||
$this->telegram->answerCallback((string) $callback['id'], 'Уже неактуально');
|
||
|
||
return;
|
||
}
|
||
|
||
if ($action === 'done') {
|
||
$this->actions->done($delivery->event);
|
||
$this->telegram->answerCallback((string) $callback['id'], 'Закрыто');
|
||
} else {
|
||
$this->actions->ack($delivery->event);
|
||
$this->telegram->answerCallback((string) $callback['id'], 'Ок, напомню позже');
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/app/Http/Controllers/Me/ChannelsController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Me;
|
||
|
||
use App\Enums\ChannelType;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Channel;
|
||
use App\Models\User;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class ChannelsController extends Controller
|
||
{
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
|
||
return response()->json(['channels' => $user->channels()->orderBy('id')->get()->map(self::present(...))->values()]);
|
||
}
|
||
|
||
public function store(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
|
||
$data = $request->validate([
|
||
'type' => ['required', 'in:webhook'],
|
||
'config' => ['required', 'array'],
|
||
'config.deliver_url' => ['required', 'url'],
|
||
'config.presence_url' => ['required', 'url'],
|
||
], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']);
|
||
|
||
$channel = $user->channels()->create([
|
||
'type' => ChannelType::Webhook,
|
||
'config' => ['deliver_url' => $data['config']['deliver_url'], 'presence_url' => $data['config']['presence_url']],
|
||
'enabled' => true,
|
||
]);
|
||
|
||
return response()->json(self::present($channel), 201);
|
||
}
|
||
|
||
public function destroy(Request $request, int $channel): Response
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
$model = $user->channels()->whereKey($channel)->firstOrFail();
|
||
if ($model->type === ChannelType::Web) {
|
||
throw ValidationException::withMessages(['channel' => 'Веб-канал удалить нельзя']);
|
||
}
|
||
$model->delete();
|
||
|
||
return response()->noContent();
|
||
}
|
||
|
||
public function telegramLink(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
$code = Str::upper(Str::random(6));
|
||
Cache::put("tg:link:{$code}", $user->id, 900);
|
||
|
||
return response()->json([
|
||
'code' => $code,
|
||
'bot_url' => 'https://t.me/'.config('hado.telegram.username').'?start='.$code,
|
||
]);
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function present(Channel $channel): array
|
||
{
|
||
return [
|
||
'id' => $channel->id,
|
||
'type' => $channel->type->value,
|
||
'config' => $channel->config,
|
||
'enabled' => $channel->enabled,
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`core/routes/web.php` — add the public routes and the channel routes (full file):
|
||
```php
|
||
<?php
|
||
|
||
use App\Http\Controllers\ActionController;
|
||
use App\Http\Controllers\Me\ChannelsController;
|
||
use App\Http\Controllers\Me\EventsController as MeEvents;
|
||
use App\Http\Controllers\Me\ProfileController;
|
||
use App\Http\Controllers\TelegramWebhookController;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
// Публичные: без SSO, без CSRF (см. bootstrap/app.php)
|
||
Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack');
|
||
Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done');
|
||
Route::post('/hooks/telegram', TelegramWebhookController::class);
|
||
|
||
// Под SSO хаба
|
||
Route::middleware('remote.user')->group(function () {
|
||
Route::get('/me', [ProfileController::class, 'show']);
|
||
Route::patch('/me', [ProfileController::class, 'update']);
|
||
Route::post('/me/heartbeat', [ProfileController::class, 'heartbeat']);
|
||
Route::get('/me/events', [MeEvents::class, 'index']);
|
||
Route::post('/me/events/{event}/ack', [MeEvents::class, 'ack']);
|
||
Route::post('/me/events/{event}/done', [MeEvents::class, 'done']);
|
||
Route::get('/me/channels', [ChannelsController::class, 'index']);
|
||
Route::post('/me/channels', [ChannelsController::class, 'store']);
|
||
Route::delete('/me/channels/{channel}', [ChannelsController::class, 'destroy']);
|
||
Route::post('/me/channels/telegram/link', [ChannelsController::class, 'telegramLink']);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "ActionLinksTest|TelegramWebhookTest|ChannelsApiTest|WebhookChannelTest"
|
||
```
|
||
Expected: all PASS. `WebhookChannelTest` still registers its own `action.*` routes in `setUp`; now that real routes exist, remove those two `Route::post` lines from that test's `setUp` and re-run: still PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: action links, telegram webhook and channels API
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Manual events and archive API
|
||
|
||
Design handoff (`docs/design/handoff/README.md`) added two features the spec did not have: manual event creation («+ Добавить») and an archive of closed events («書庫 Архив · N закрыто»). Both stay. Backend support goes here; the page uses it in Task 12.
|
||
|
||
**Files:**
|
||
- Modify: `core/app/Http/Controllers/Me/EventsController.php` (add `store`, `archive`)
|
||
- Modify: `core/routes/web.php` (add `POST /me/events`, `GET /me/archive`)
|
||
- Test: `core/tests/Feature/ManualEventsTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EventUpserter::upsert(Source, items)`, `ApiPresenter::present`.
|
||
- Produces:
|
||
- `POST /me/events` `{title, subtitle?, due_date (Y-m-d), due_time? (H:i)}` → 201 with the presented event. Source is the reserved `manual` source (`Source::firstOrCreate(['name' => 'manual'])` with an unusable random token hash). `source_ref = "manual:<ulid>"`, `topic = ""`, `after_due = keep`, `fire_at = due_date − 30 days` (so a manual event is «Грядёт» within 30 days and «Запланировано» before that, exactly as the prototype groups it), payload `{title, subtitle?, done_label: "Сделано"}`.
|
||
- `GET /me/archive` → `{events: [...]}` — the user's `done` and `expired` events, newest closed first (`updated_at desc`), max 100, each presented plus `closed_as`: `done_label` (default «Сделано») for `done`, «Истекло» for `expired`. `withdrawn` is not shown.
|
||
- `present()` gains `source` for `/me` responses (already added in `index`); manual events show `source = "manual"`; the page renders it as «вручную».
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`core/tests/Feature/ManualEventsTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class ManualEventsTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private array $as = ['X-Remote-User' => 'nikita'];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
config(['hado.default_tz' => 'UTC']);
|
||
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function test_creates_manual_event_grouped_by_date(): void
|
||
{
|
||
$this->postJson('/me/events', ['title' => 'Продлить страховку', 'subtitle' => 'раз в год', 'due_date' => '2026-09-20'], $this->as)
|
||
->assertCreated()
|
||
->assertJsonPath('state', 'preparing')
|
||
->assertJsonPath('source', 'manual')
|
||
->assertJsonPath('payload.title', 'Продлить страховку')
|
||
->assertJsonPath('payload.done_label', 'Сделано')
|
||
->assertJsonPath('fire_at', '2026-08-21');
|
||
|
||
$this->postJson('/me/events', ['title' => 'Далеко', 'due_date' => '2026-12-15'], $this->as)->assertJsonPath('state', 'scheduled');
|
||
$this->postJson('/me/events', ['title' => 'Сегодня', 'due_date' => '2026-09-04', 'due_time' => '19:40'], $this->as)
|
||
->assertJsonPath('state', 'today')->assertJsonPath('due_at', '2026-09-04T19:40');
|
||
$this->postJson('/me/events', ['title' => 'Прошло', 'due_date' => '2026-09-01'], $this->as)->assertJsonPath('state', 'overdue');
|
||
|
||
$this->assertSame(4, Event::count());
|
||
$this->assertSame('manual', Source::sole()->name);
|
||
$this->assertSame(AfterDue::Keep, Event::first()->after_due);
|
||
$this->assertSame(DueMode::Local, Event::first()->due_mode);
|
||
}
|
||
|
||
public function test_validation(): void
|
||
{
|
||
$this->postJson('/me/events', ['due_date' => '2026-09-20'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['title']);
|
||
$this->postJson('/me/events', ['title' => 'x'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_date']);
|
||
$this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20', 'due_time' => '25:00'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_time']);
|
||
}
|
||
|
||
public function test_manual_source_cannot_be_used_through_client_api(): void
|
||
{
|
||
$this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20'], $this->as)->assertCreated();
|
||
$this->putJson('/api/events', ['events' => []], ['Authorization' => 'Bearer manual'])->assertStatus(401);
|
||
}
|
||
|
||
public function test_archive_lists_done_and_expired_newest_first(): void
|
||
{
|
||
$this->getJson('/me', $this->as);
|
||
$user = User::sole();
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$make = fn (string $topic, EventState $state, string $updatedAt, ?string $doneLabel = null) => tap(Event::create([
|
||
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||
'due_mode' => DueMode::Local, 'due_date' => '2026-08-28', 'due_time' => '24:00:00', 'fire_on' => '2026-08-28',
|
||
'after_due' => AfterDue::Expire, 'payload' => array_filter(['title' => "E $topic", 'done_label' => $doneLabel]),
|
||
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]), fn (Event $e) => Event::whereKey($e->id)->update(['updated_at' => $updatedAt]));
|
||
$make('a', EventState::Done, '2026-09-01 10:00:00', 'Поздравил');
|
||
$make('b', EventState::Expired, '2026-09-02 10:00:00');
|
||
$make('c', EventState::Withdrawn, '2026-09-03 10:00:00');
|
||
$make('d', EventState::Today, '2026-09-03 10:00:00');
|
||
|
||
$r = $this->getJson('/me/archive', $this->as)->assertOk()->assertJsonCount(2, 'events');
|
||
$this->assertSame(['b', 'a'], array_column($r->json('events'), 'topic'));
|
||
$this->assertSame(['Истекло', 'Поздравил'], array_column($r->json('events'), 'closed_as'));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter ManualEventsTest
|
||
```
|
||
Expected: FAIL (405/404).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Add to `core/app/Http/Controllers/Me/EventsController.php`:
|
||
```php
|
||
use App\Ingest\EventUpserter;
|
||
use App\Models\Source;
|
||
use Illuminate\Support\Str;
|
||
|
||
// constructor becomes:
|
||
public function __construct(private readonly EventActions $actions, private readonly EventUpserter $upserter) {}
|
||
|
||
public function store(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
$data = $request->validate([
|
||
'title' => ['required', 'string', 'max:200'],
|
||
'subtitle' => ['sometimes', 'nullable', 'string', 'max:200'],
|
||
'due_date' => ['required', 'date_format:Y-m-d'],
|
||
'due_time' => ['sometimes', 'nullable', 'date_format:H:i'],
|
||
]);
|
||
|
||
$source = Source::firstOrCreate(['name' => 'manual'], ['token_hash' => hash('sha256', Str::random(64))]);
|
||
$dueAt = $data['due_date'].(empty($data['due_time']) ? '' : 'T'.$data['due_time']);
|
||
|
||
[$result] = $this->upserter->upsert($source, [[
|
||
'user' => $user->login,
|
||
'source_ref' => 'manual:'.Str::ulid(),
|
||
'topic' => '',
|
||
'due_at' => $dueAt,
|
||
'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
|
||
'after_due' => 'keep',
|
||
'payload' => array_filter([
|
||
'title' => $data['title'],
|
||
'subtitle' => $data['subtitle'] ?? null,
|
||
'done_label' => 'Сделано',
|
||
], fn ($v) => $v !== null && $v !== ''),
|
||
]]);
|
||
|
||
$event = Event::with('source')->findOrFail($result['id']);
|
||
|
||
return response()->json(ApiPresenter::present($event) + ['source' => $event->source->name], 201);
|
||
}
|
||
|
||
public function archive(Request $request): JsonResponse
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
$events = $user->events()->with('source')
|
||
->whereIn('state', [EventState::Done->value, EventState::Expired->value])
|
||
->orderByDesc('updated_at')->limit(100)->get();
|
||
|
||
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + [
|
||
'source' => $e->source->name,
|
||
'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
|
||
])->values()]);
|
||
}
|
||
```
|
||
|
||
Routes, inside the `remote.user` group in `core/routes/web.php`:
|
||
```php
|
||
Route::post('/me/events', [MeEvents::class, 'store']);
|
||
Route::get('/me/archive', [MeEvents::class, 'archive']);
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter "ManualEventsTest|MeApiTest"
|
||
```
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 5: Spec and functional doc**
|
||
|
||
In `docs/superpowers/specs/2026-09-03-hado-core-design.md` §10 add after the route list: «`POST /me/events` — ручное событие: источник `manual` (зарезервирован, токена нет), `fire_at = срок − 30 дней`, `after_due = keep`. `GET /me/archive` — закрытые (`done`, `expired`) за период retention, новые сверху, с подписью закрытия.» Update the inbox paragraph to mention the archive block and the add form. These were added by the design handoff on 2026-09-04.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: manual events and archive API
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Web inbox page (по дизайну handoff)
|
||
|
||
The page follows `docs/design/handoff/hado-inbox.dc.html` (working prototype) and `docs/design/handoff/README.md` (tokens and rules). Reproduce it in Blade + vanilla JS; the values below are copied from the prototype, not invented. Dark only. Sekai design system: 60% dusk, 30% text neutrals, ~10% cold accent (`--ruri`/`--byakugun`) on action buttons, counter and time. `--ruri` is never used as text. Errors are `--sakura`.
|
||
|
||
**Files:**
|
||
- Create: `core/app/Http/Controllers/InboxController.php`, `core/resources/views/inbox.blade.php`, `core/public/inbox.js`
|
||
- Modify: `core/routes/web.php` (add `GET /` inside the `remote.user` group)
|
||
- Delete: `core/resources/views/welcome.blade.php`
|
||
- Test: `core/tests/Feature/InboxPageTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `/me`, `/me/events` (GET/POST), `/me/events/{id}/ack|done`, `/me/archive`, `/me/channels*`, `/me/heartbeat`.
|
||
- Produces: `GET /` rendering the whole inbox server-side (groups, archive rows, settings); JS only toggles sections, submits forms and sends the heartbeat; every mutation reloads the page.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`core/tests/Feature/InboxPageTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class InboxPageTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
config(['hado.default_tz' => 'UTC']);
|
||
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function test_requires_sso(): void
|
||
{
|
||
$this->get('/')->assertStatus(401);
|
||
}
|
||
|
||
public function test_renders_groups_cards_archive_and_settings(): void
|
||
{
|
||
$this->get('/', ['X-Remote-User' => 'nikita']);
|
||
$user = User::sole();
|
||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||
$manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]);
|
||
$make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([
|
||
'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due,
|
||
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]),
|
||
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||
]);
|
||
$make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил');
|
||
$make($source, 'b', EventState::Overdue, '2026-09-01');
|
||
$make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null);
|
||
$make($source, 'd', EventState::Scheduled, '2026-12-15');
|
||
$make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил');
|
||
$make($source, 'f', EventState::Expired, '2026-08-20');
|
||
|
||
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
|
||
|
||
// шапка и счётчик
|
||
$this->assertStringContainsString('<title>(2) Hado</title>', $html);
|
||
$this->assertStringContainsString('波動', $html);
|
||
$this->assertStringContainsString('+ Добавить', $html);
|
||
// группы в порядке и с кандзи
|
||
$this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html);
|
||
// карточки
|
||
$this->assertStringContainsString('Событие a', $html);
|
||
$this->assertStringContainsString('19:40', $html);
|
||
$this->assertStringContainsString('СЕН', $html);
|
||
$this->assertStringContainsString('сегодня', $html);
|
||
$this->assertStringContainsString('3 дня назад', $html);
|
||
$this->assertStringContainsString('через 26 дней', $html);
|
||
$this->assertStringContainsString('вручную', $html);
|
||
$this->assertStringContainsString('Поздравил', $html);
|
||
$this->assertStringContainsString('Помню', $html);
|
||
// архив
|
||
$this->assertStringContainsString('2 закрыто', $html);
|
||
$this->assertStringContainsString('Оплатил', $html);
|
||
$this->assertStringContainsString('Истекло', $html);
|
||
// настройки, форма добавления, присутствие
|
||
$this->assertStringContainsString('nikita', $html);
|
||
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
|
||
$this->assertStringContainsString('/inbox.js', $html);
|
||
$this->assertStringContainsString('csrf-token', $html);
|
||
}
|
||
|
||
public function test_counter_hidden_when_zero(): void
|
||
{
|
||
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
|
||
$this->assertStringContainsString('<title>Hado</title>', $html);
|
||
$this->assertStringNotContainsString('class="counter"', $html);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter InboxPageTest
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Controller**
|
||
|
||
`core/app/Http/Controllers/InboxController.php`:
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Enums\EventState;
|
||
use App\Http\Controllers\Api\EventsController as Presenter;
|
||
use App\Http\Controllers\Me\ChannelsController;
|
||
use App\Http\Controllers\Me\ProfileController;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use App\Scheduling\DueResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Contracts\View\View;
|
||
use Illuminate\Http\Request;
|
||
|
||
class InboxController extends Controller
|
||
{
|
||
private const MONTHS = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];
|
||
|
||
public function __construct(private readonly DueResolver $due) {}
|
||
|
||
public function __invoke(Request $request): View
|
||
{
|
||
/** @var User $user */
|
||
$user = $request->attributes->get('user');
|
||
$today = $user->now()->startOfDay();
|
||
|
||
$live = $user->events()->with('source')
|
||
->whereIn('state', EventState::nonTerminalValues())
|
||
->get()
|
||
->map(fn (Event $e) => $this->card($e, $user, $today))
|
||
->sortBy('sort')->values();
|
||
|
||
$groups = [];
|
||
foreach (['today' => ['今日', 'СЕГОДНЯ'], 'overdue' => ['遅延', 'ПРОСРОЧЕНО'], 'preparing' => ['近日', 'ГРЯДЁТ'], 'scheduled' => ['予定', 'ЗАПЛАНИРОВАНО']] as $state => [$jp, $ru]) {
|
||
$groups[] = ['state' => $state, 'jp' => $jp, 'ru' => $ru, 'events' => $live->where('state', $state)->values()->all()];
|
||
}
|
||
|
||
$archive = $user->events()->with('source')
|
||
->whereIn('state', [EventState::Done->value, EventState::Expired->value])
|
||
->orderByDesc('updated_at')->limit(100)->get()
|
||
->map(fn (Event $e) => [
|
||
'title' => $e->payload['title'],
|
||
'source' => self::sourceLabel($e->source->name),
|
||
'due' => Presenter::present($e)['due_at'],
|
||
'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
|
||
])->values()->all();
|
||
|
||
$profile = ProfileController::present($user);
|
||
|
||
return view('inbox', [
|
||
'profile' => $profile,
|
||
'title' => $profile['badge'] > 0 ? "({$profile['badge']}) Hado" : 'Hado',
|
||
'groups' => $groups,
|
||
'archive' => $archive,
|
||
'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(),
|
||
'timezones' => \DateTimeZone::listIdentifiers(),
|
||
]);
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function card(Event $e, User $user, CarbonImmutable $today): array
|
||
{
|
||
$due = $this->due->resolve($e, $user);
|
||
$wholeDay = $due->moment->eq($due->day->addDay());
|
||
$diff = (int) round($today->diffInDays($due->day));
|
||
|
||
return [
|
||
'id' => $e->id,
|
||
'state' => $e->state->value,
|
||
'title' => $e->payload['title'],
|
||
'subtitle' => $e->payload['subtitle'] ?? null,
|
||
'link' => $e->payload['deep_link'] ?? null,
|
||
'done_label' => $e->payload['done_label'] ?? 'Сделано',
|
||
'can_remind' => $e->state !== EventState::Scheduled,
|
||
'source' => self::sourceLabel($e->source->name),
|
||
'due' => Presenter::present($e)['due_at'],
|
||
'due_month' => self::MONTHS[(int) $due->day->format('n') - 1],
|
||
'due_day' => $due->day->format('d'),
|
||
'due_time' => $wholeDay ? null : $due->moment->format('H:i'),
|
||
'rel' => self::relative($diff),
|
||
'sort' => $due->moment->getTimestamp(),
|
||
];
|
||
}
|
||
|
||
public static function sourceLabel(string $name): string
|
||
{
|
||
return $name === 'manual' ? 'вручную' : $name;
|
||
}
|
||
|
||
public static function relative(int $diff): string
|
||
{
|
||
$days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней');
|
||
|
||
return match (true) {
|
||
$diff === 0 => 'сегодня',
|
||
$diff === 1 => 'завтра',
|
||
$diff > 1 => "через {$diff} ".$days($diff),
|
||
$diff === -1 => 'вчера',
|
||
default => abs($diff).' '.$days(abs($diff)).' назад',
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Blade template**
|
||
|
||
`core/resources/views/inbox.blade.php` — structure and values mirror the prototype; sister planets, nebula and star field included as in the Sekai template:
|
||
```blade
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<title>{{ $title }}</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,.16);--hair-2:rgba(174,180,214,.30)}
|
||
*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg)}
|
||
body{min-height:100vh;color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow-x:hidden;display:flex;flex-direction:column}
|
||
::selection{background:rgba(143,214,220,.25)}
|
||
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
|
||
input,select,button{font:inherit}button[disabled]{opacity:.45;pointer-events:none}
|
||
.mono{font-family:'Space Mono',monospace}.serif{font-family:'Shippori Mincho',serif}
|
||
.layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
|
||
.nebula{position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%,rgba(232,78,138,.14),transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%,rgba(159,143,203,.17),transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%,rgba(143,214,220,.11),transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%,rgba(53,92,214,.11),transparent 72%)}
|
||
.core{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%,rgba(247,206,220,.22),transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%,rgba(242,236,224,.18),transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%,rgba(234,143,174,.22),transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%,rgba(247,206,220,.24),transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%,rgba(242,236,224,.17),transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%,rgba(247,206,220,.18),transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%,rgba(143,214,220,.12),transparent 70%)}
|
||
.dust{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%,rgba(7,9,18,.6),transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%,rgba(9,11,22,.55),transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%,rgba(7,9,18,.55),transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%,rgba(9,11,22,.5),transparent 75%)}
|
||
.band{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
|
||
.sister{position:absolute}.sister .col{display:flex;flex-direction:column;align-items:center;gap:9px}
|
||
.skdot{pointer-events:auto;border-radius:50%}.sklbl{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s}.skdot:hover+.sklbl{opacity:1}
|
||
header{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0}
|
||
.brand{display:flex;align-items:baseline;gap:14px}.brand .jp{font-family:'Shippori Mincho',serif;font-size:54px;line-height:1}.brand .lat{font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)}
|
||
.counter{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)}
|
||
.counter .dot{width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,var(--ruri),#24337F 55%,var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite}
|
||
.counter .n{font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)}
|
||
.tools{display:flex;align-items:center;gap:16px;margin-top:14px}.num{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
|
||
.btn{border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s,border-color .2s,color .2s}
|
||
.primary{background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun)}.primary:hover{background:rgba(53,92,214,.4)}
|
||
.ghost{background:transparent;border:1px solid var(--hair-2);color:var(--kasumi)}.ghost:hover{border-color:var(--byakugun);color:var(--byakugun)}
|
||
.ghost.danger:hover{border-color:var(--nadeshiko);color:var(--sakura)}
|
||
main{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px;flex:1}
|
||
.panel{border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden;margin-top:64px}
|
||
.panel>button.head{width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)}
|
||
.panel .head .jp{font-family:'Shippori Mincho',serif;font-size:16px}.panel .head .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)}.panel .head .chev{margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)}
|
||
.panel .body{border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px}
|
||
.addform{margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px}
|
||
.label{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px}
|
||
.row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end}
|
||
.field{display:flex;flex-direction:column;gap:6px}.field span{font-size:12px;color:var(--nibi)}
|
||
.field input,.field select{background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px}
|
||
.err{color:var(--sakura);font-size:12px;margin-top:12px}.ok{color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px}
|
||
.group{margin-bottom:52px}.group .h{display:flex;align-items:baseline;gap:12px;margin-bottom:18px}.group .h .jp{font-family:'Shippori Mincho',serif;font-size:20px}.group .h .ru{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)}.group .h .line{flex:1;border-bottom:1px solid var(--hair)}
|
||
.empty{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px}
|
||
.cards{display:flex;flex-direction:column;gap:12px}
|
||
.card{background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||
.plaque{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)}
|
||
.plaque .m{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)}.plaque .d{font-family:'Shippori Mincho',serif;font-size:24px;line-height:1}.plaque .t{font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)}
|
||
.card .text{flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px}
|
||
.card .title{font-size:16px;font-weight:500;color:var(--washi);align-self:flex-start}.card a.title{text-decoration:underline dotted var(--hair-2);text-underline-offset:4px}.card a.title:hover{color:var(--byakugun)}
|
||
.card .sub{font-size:13px;color:var(--kasumi)}.card .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px}.card .meta .rel{color:var(--kasumi)}
|
||
.card .actions{display:flex;gap:10px;align-items:center}
|
||
.arow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75}
|
||
.arow .t{font-size:14px;color:var(--kasumi);flex:1;min-width:180px}.arow .m{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)}.arow .tag{font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px}
|
||
.chrow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)}.chrow .n{font-size:14px;min-width:76px}.chrow .note{font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere}
|
||
.tgbox{border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px}
|
||
footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
|
||
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
|
||
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
|
||
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
|
||
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
|
||
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
|
||
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
|
||
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
|
||
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="layer" id="stars"></div>
|
||
<div class="nebula"></div><div class="core"></div><div class="dust"></div>
|
||
<div class="band" id="band"></div>
|
||
|
||
{{-- сёстры-планеты: все, кроме Hado --}}
|
||
<div class="layer">
|
||
<div class="sister" style="top:17%;left:7%"><div style="animation:skOrbXa 19s ease-in-out infinite"><div class="col" style="animation:skOrbYa 19s ease-in-out -4.75s infinite"><div class="skdot" style="width:15px;height:15px;background:radial-gradient(circle at 32% 28%,var(--sakura),var(--nadeshiko) 50%,var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div><span class="sklbl">YUME</span></div></div></div>
|
||
<div class="sister" style="top:26%;right:8%"><div style="animation:skOrbXb 27s ease-in-out infinite"><div class="col" style="animation:skOrbYb 27s ease-in-out -20.25s infinite"><div class="skdot" style="width:12px;height:12px;background:radial-gradient(circle at 32% 28%,var(--tsutsuji),var(--kobai) 52%,var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div><span class="sklbl">MYAKU</span></div></div></div>
|
||
<div class="sister" style="top:60%;left:4.5%"><div style="animation:skOrbXc 14s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite"><div class="skdot" style="width:9px;height:9px;background:radial-gradient(circle at 34% 30%,var(--fuji),#4A4177 55%,var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div><span class="sklbl">KIOKU</span></div></div></div>
|
||
<div class="sister" style="top:64%;right:6%"><div style="animation:skOrbXd 22s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite"><div class="skdot" style="width:10px;height:10px;background:radial-gradient(circle at 32% 28%,var(--byakugun),var(--hanada) 50%,var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div><span class="sklbl">FUMI</span></div></div></div>
|
||
</div>
|
||
|
||
<header>
|
||
<div class="brand">
|
||
<span class="jp">波動</span><span class="lat">HADO</span>
|
||
@if($profile['badge'] > 0)<span class="counter"><span class="dot"></span><span class="n">{{ $profile['badge'] }}</span></span>@endif
|
||
</div>
|
||
<div class="tools">
|
||
<button class="btn primary" id="toggle-add">+ Добавить</button>
|
||
<span class="num">05</span>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="addform" id="addform" hidden>
|
||
<div class="label">НОВОЕ СОБЫТИЕ</div>
|
||
<form class="row" id="add">
|
||
<label class="field" style="flex:2;min-width:220px"><span>Заголовок</span><input name="title" placeholder="Продлить страховку" required maxlength="200"></label>
|
||
<label class="field" style="flex:1;min-width:160px"><span>Подзаголовок (опц.)</span><input name="subtitle" placeholder="раз в год" maxlength="200"></label>
|
||
<label class="field"><span>Срок</span><input name="due_date" type="date" required></label>
|
||
<label class="field"><span>Время (опц.)</span><input name="due_time" type="time"></label>
|
||
<button type="submit" class="btn primary">Создать</button>
|
||
<button type="button" class="btn ghost" id="cancel-add">Отмена</button>
|
||
</form>
|
||
<div class="err" id="add-err" hidden></div>
|
||
</section>
|
||
|
||
@foreach($groups as $group)
|
||
<section class="group">
|
||
<div class="h"><span class="jp">{{ $group['jp'] }}</span><span class="ru">{{ $group['ru'] }}</span><span class="line"></span></div>
|
||
@if(empty($group['events']))<div class="empty">ПУСТО</div>@endif
|
||
<div class="cards">
|
||
@foreach($group['events'] as $ev)
|
||
<article class="card" data-id="{{ $ev['id'] }}">
|
||
<div class="plaque"><span class="m">{{ $ev['due_month'] }}</span><span class="d">{{ $ev['due_day'] }}</span>@if($ev['due_time'])<span class="t">{{ $ev['due_time'] }}</span>@endif</div>
|
||
<div class="text">
|
||
@if($ev['link'])<a class="title" href="{{ $ev['link'] }}">{{ $ev['title'] }}</a>@else<span class="title">{{ $ev['title'] }}</span>@endif
|
||
@if($ev['subtitle'])<span class="sub">{{ $ev['subtitle'] }}</span>@endif
|
||
<span class="meta">{{ $ev['source'] }} · срок {{ $ev['due'] }} · <span class="rel">{{ $ev['rel'] }}</span></span>
|
||
</div>
|
||
<div class="actions">
|
||
@if($ev['can_remind'])<button class="btn ghost" data-action="ack">Помню</button>@endif
|
||
<button class="btn primary" data-action="done">{{ $ev['done_label'] }}</button>
|
||
</div>
|
||
</article>
|
||
@endforeach
|
||
</div>
|
||
</section>
|
||
@endforeach
|
||
|
||
<section class="panel" data-panel="archive">
|
||
<button class="head" type="button"><span class="jp">書庫</span><span>Архив</span><span class="meta">{{ count($archive) }} закрыто</span><span class="chev">▼</span></button>
|
||
<div class="body" hidden>
|
||
@if(empty($archive))<div class="empty">ПУСТО</div>@endif
|
||
@foreach($archive as $ar)
|
||
<div class="arow"><span class="t">{{ $ar['title'] }}</span><span class="m">{{ $ar['source'] }} · {{ $ar['due'] }}</span><span class="tag">{{ $ar['closed_as'] }}</span></div>
|
||
@endforeach
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" data-panel="settings">
|
||
<button class="head" type="button"><span class="jp">設定</span><span>Настройки</span><span class="meta">{{ $profile['login'] }}</span><span class="chev">▼</span></button>
|
||
<div class="body" hidden style="padding:26px 22px 30px;gap:34px">
|
||
<div>
|
||
<div class="label">ПОЯС И ТИХИЕ ЧАСЫ</div>
|
||
<form class="row" id="profile">
|
||
<label class="field" style="min-width:240px"><span>Часовой пояс</span>
|
||
<select name="tz">@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach</select>
|
||
</label>
|
||
<label class="field"><span>Тихие часы, с</span><input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
|
||
<label class="field"><span>до</span><input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
|
||
<button type="submit" class="btn primary">Сохранить</button>
|
||
</form>
|
||
<div class="err" id="profile-err" hidden></div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="label">КАНАЛЫ</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
@foreach($channels as $ch)
|
||
<div class="chrow">
|
||
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
|
||
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span>
|
||
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="label">ДОБАВИТЬ WEBHOOK</div>
|
||
<form class="row" id="webhook">
|
||
<label class="field" style="flex:1;min-width:220px"><span>Адрес доставки</span><input name="deliver_url" type="url" placeholder="https://home.local/hado/notify" required></label>
|
||
<label class="field" style="flex:1;min-width:220px"><span>Адрес присутствия</span><input name="presence_url" type="url" placeholder="https://home.local/presence" required></label>
|
||
<button type="submit" class="btn ghost">Добавить webhook</button>
|
||
</form>
|
||
<div class="err" id="webhook-err" hidden></div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="label">TELEGRAM</div>
|
||
<button class="btn ghost" id="tg-connect">Подключить Telegram</button>
|
||
<div class="tgbox" id="tg-box" hidden>
|
||
<span style="font-size:14px">Отправь боту <span class="mono" style="color:var(--byakugun)" id="tg-code"></span> или <a id="tg-link" href="#" target="_blank" rel="noopener">открой</a></span>
|
||
<span class="mono" style="font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<footer><span>波動 HADO</span><span>· 世界 SEKAI</span></footer>
|
||
<script src="/inbox.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 5: Page script**
|
||
|
||
`core/public/inbox.js`:
|
||
```js
|
||
(() => {
|
||
const csrf = document.querySelector('meta[name=csrf-token]').content;
|
||
const call = (method, url, body) => fetch(url, {
|
||
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const showErr = async (el, r) => {
|
||
const d = await r.json().catch(() => ({}));
|
||
el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
|
||
el.hidden = false;
|
||
};
|
||
|
||
// Звёзды: 90 по небу, 170 в полосе (дуга −14°)
|
||
const star = (color, size, bright, x, y, dur, delay) => {
|
||
const s = document.createElement('div');
|
||
s.style.cssText = `position:absolute;border-radius:50%;left:${x}%;top:${y}%;width:${size}px;height:${size}px;background:${color};${bright ? `box-shadow:0 0 6px ${color};` : ''}animation:skTwinkle ${dur}s ease-in-out ${delay}s infinite`;
|
||
return s;
|
||
};
|
||
const sky = document.getElementById('stars'), band = document.getElementById('band');
|
||
for (let i = 0; i < 90; i++) sky.appendChild(star(Math.random() < .2 ? '#8FD6DC' : '#F2ECE0', Math.random() < .85 ? 1.5 : 2.5, false, (Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
|
||
for (let j = 0; j < 170; j++) {
|
||
const bright = Math.random() < .08, r = Math.random();
|
||
const color = r < .68 ? '#F2ECE0' : (r < .88 ? '#F7CEDC' : '#8FD6DC');
|
||
const g = (Math.random() + Math.random() + Math.random()) / 3, x = Math.random() * 100;
|
||
const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * .35;
|
||
band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright, x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
|
||
}
|
||
|
||
// Панели архив/настройки
|
||
document.querySelectorAll('.panel .head').forEach(h => h.addEventListener('click', () => {
|
||
const body = h.nextElementSibling; body.hidden = !body.hidden;
|
||
h.querySelector('.chev').textContent = body.hidden ? '▼' : '▲';
|
||
}));
|
||
|
||
// Форма добавления
|
||
const addform = document.getElementById('addform');
|
||
const toggleAdd = () => { addform.hidden = !addform.hidden; document.getElementById('add-err').hidden = true; };
|
||
document.getElementById('toggle-add').addEventListener('click', toggleAdd);
|
||
document.getElementById('cancel-add').addEventListener('click', toggleAdd);
|
||
document.getElementById('add').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const f = new FormData(e.target);
|
||
const r = await call('POST', '/me/events', { title: f.get('title'), subtitle: f.get('subtitle') || null, due_date: f.get('due_date'), due_time: f.get('due_time') || null });
|
||
if (r.ok) location.reload(); else showErr(document.getElementById('add-err'), r);
|
||
});
|
||
|
||
// «Помню» / «Сделано»
|
||
document.querySelectorAll('.card button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
|
||
const card = btn.closest('.card');
|
||
card.querySelectorAll('button').forEach(b => b.disabled = true);
|
||
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
|
||
if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false);
|
||
}));
|
||
|
||
// Настройки
|
||
document.getElementById('profile').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const f = new FormData(e.target);
|
||
const r = await call('PATCH', '/me', { tz: f.get('tz'), quiet_start: f.get('quiet_start'), quiet_end: f.get('quiet_end') });
|
||
if (r.ok) location.reload(); else showErr(document.getElementById('profile-err'), r);
|
||
});
|
||
document.getElementById('webhook').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const f = new FormData(e.target);
|
||
const r = await call('POST', '/me/channels', { type: 'webhook', config: { deliver_url: f.get('deliver_url'), presence_url: f.get('presence_url') } });
|
||
if (r.ok) location.reload(); else showErr(document.getElementById('webhook-err'), r);
|
||
});
|
||
document.querySelectorAll('[data-delete-channel]').forEach(btn => btn.addEventListener('click', async () => {
|
||
const r = await call('DELETE', `/me/channels/${btn.dataset.deleteChannel}`);
|
||
if (r.ok) location.reload();
|
||
}));
|
||
document.getElementById('tg-connect').addEventListener('click', async e => {
|
||
const r = await call('POST', '/me/channels/telegram/link');
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
document.getElementById('tg-code').textContent = `/start ${d.code}`;
|
||
document.getElementById('tg-link').href = d.bot_url;
|
||
e.target.hidden = true; document.getElementById('tg-box').hidden = false;
|
||
});
|
||
|
||
// Присутствие: heartbeat только при видимой вкладке и активности за 3 минуты.
|
||
let lastActivity = Date.now();
|
||
['pointerdown', 'pointermove', 'keydown', 'scroll'].forEach(ev => addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true }));
|
||
const heartbeat = () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (Date.now() - lastActivity > 3 * 60 * 1000) return;
|
||
call('POST', '/me/heartbeat').catch(() => {});
|
||
};
|
||
heartbeat();
|
||
setInterval(heartbeat, 30 * 1000);
|
||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { lastActivity = Date.now(); heartbeat(); } });
|
||
})();
|
||
```
|
||
Add to the `remote.user` group in `core/routes/web.php`:
|
||
```php
|
||
Route::get('/', \App\Http\Controllers\InboxController::class);
|
||
```
|
||
Delete `core/resources/views/welcome.blade.php`.
|
||
|
||
- [ ] **Step 6: Run tests, Pint, whole suite**
|
||
|
||
```bash
|
||
cd core && php artisan test --filter InboxPageTest && php artisan test && vendor/bin/pint --test
|
||
```
|
||
Expected: all green. In `test_renders_groups_cards_archive_and_settings` the relative strings depend on `setTestNow` (2026-09-04): «сегодня» for 09-04, «3 дня назад» for 09-01, «через 26 дней» for 09-30.
|
||
|
||
- [ ] **Step 7: Visual check against the prototype**
|
||
|
||
Open `docs/design/handoff/hado-inbox.dc.html` in a browser (keep `support.js` next to it) and `http://localhost:8080/` with an `X-Remote-User` header (Task 13 compose). Compare: header, counter pill, group headings, card plaque, buttons, archive rows, settings. Fix spacing or token mismatches in `inbox.blade.php` only; do not change behavior.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox per Sekai design handoff
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Docker Compose, Dockerfiles, Caddy snippet, README
|
||
|
||
**Files:**
|
||
- Create: `docker-compose.yml`, `core/Dockerfile`, `core/Dockerfile.dev`, `deploy/Caddyfile.snippet`, `README.md`, `.env.example` (root, for compose)
|
||
|
||
**Interfaces:**
|
||
- Produces: `docker compose up -d --build` runs `app` (:8080), `scheduler`, `worker`, `postgres`; `docker compose exec app php artisan migrate` and `hado:source:create` work.
|
||
|
||
- [ ] **Step 1: Files**
|
||
|
||
`core/Dockerfile.dev`:
|
||
```dockerfile
|
||
FROM php:8.4-cli
|
||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev git unzip \
|
||
&& docker-php-ext-install pdo_pgsql pgsql \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||
WORKDIR /app
|
||
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
||
```
|
||
|
||
`core/Dockerfile` (prod, same base as sekai):
|
||
```dockerfile
|
||
FROM serversideup/php:8.4-fpm-nginx AS base
|
||
USER www-data
|
||
COPY --chown=www-data:www-data . /var/www/html
|
||
RUN composer install --no-dev --optimize-autoloader
|
||
```
|
||
|
||
`docker-compose.yml`:
|
||
```yaml
|
||
services:
|
||
app:
|
||
build:
|
||
context: ./core
|
||
dockerfile: Dockerfile.dev
|
||
volumes:
|
||
- ./core:/app
|
||
ports:
|
||
- "8080:8000"
|
||
env_file: ./core/.env
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
|
||
scheduler:
|
||
build:
|
||
context: ./core
|
||
dockerfile: Dockerfile.dev
|
||
command: php artisan schedule:work
|
||
volumes:
|
||
- ./core:/app
|
||
env_file: ./core/.env
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
|
||
worker:
|
||
build:
|
||
context: ./core
|
||
dockerfile: Dockerfile.dev
|
||
command: php artisan queue:work --tries=3 --sleep=1
|
||
volumes:
|
||
- ./core:/app
|
||
env_file: ./core/.env
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
|
||
postgres:
|
||
image: postgres:17
|
||
environment:
|
||
POSTGRES_DB: hado
|
||
POSTGRES_USER: hado
|
||
POSTGRES_PASSWORD: hado
|
||
volumes:
|
||
- pg_data:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U hado -d hado"]
|
||
interval: 5s
|
||
timeout: 3s
|
||
retries: 10
|
||
|
||
volumes:
|
||
pg_data:
|
||
```
|
||
|
||
`deploy/Caddyfile.snippet` — to paste into the sekai hub Caddyfile:
|
||
```caddyfile
|
||
# Hado: публичные пути (клиенты по токену, кнопки из каналов, Telegram) идут мимо SSO,
|
||
# всё остальное — через forward_auth хаба. Порядок матчеров важен.
|
||
hado.{$DOMAIN} {
|
||
@public path /api/* /a/* /hooks/* /up
|
||
handle @public {
|
||
reverse_proxy <hado-wireguard-ip>:8080
|
||
}
|
||
handle {
|
||
import protected
|
||
reverse_proxy <hado-wireguard-ip>:8080
|
||
}
|
||
}
|
||
```
|
||
|
||
`README.md`:
|
||
~~~markdown
|
||
# Hado — 波 · ядро умных уведомлений
|
||
|
||
Сервисы пушат события с датой, ядро само считает каскад напоминаний, окна и
|
||
доставляет туда, где ты сейчас доступен (веб, Telegram, webhook/Home Assistant).
|
||
Спека: `docs/superpowers/specs/2026-09-03-hado-core-design.md`.
|
||
|
||
## Dev
|
||
|
||
```bash
|
||
cp core/.env.example core/.env
|
||
docker compose up -d --build
|
||
docker compose exec app php artisan key:generate
|
||
docker compose exec app php artisan migrate
|
||
docker compose exec app php artisan hado:source:create docs # токен печатается один раз
|
||
```
|
||
|
||
- http://localhost:8080/ — инбокс (нужен заголовок `X-Remote-User`; локально: расширение
|
||
браузера для заголовков или `curl -H 'X-Remote-User: nikita' localhost:8080/me`)
|
||
- `PUT /api/events` с `Authorization: Bearer <токен>` — см. спеку §5
|
||
|
||
Тесты локально: `cd core && php artisan test` (SQLite in-memory, нужны расширения
|
||
`pdo_sqlite`, `intl` в php.ini). Стиль: `vendor/bin/pint`.
|
||
|
||
## Telegram
|
||
|
||
1. Создай бота у @BotFather, положи токен и username в `core/.env`.
|
||
2. Придумай `TELEGRAM_WEBHOOK_SECRET`, зарегистрируй webhook:
|
||
`curl "https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://hado.<домен>/hooks/telegram&secret_token=<SECRET>"`
|
||
3. В инбоксе «Подключить Telegram» → отправь боту `/start <код>`.
|
||
|
||
## Home Assistant
|
||
|
||
В инбоксе добавь webhook-канал: `deliver_url` — HA webhook-триггер
|
||
(`https://ha/api/webhook/<id>`), `presence_url` — любой URL, отвечающий
|
||
`{"present": true|false}` (например, template-сенсор через REST API или Node-RED).
|
||
Автоматизация в HA получает JSON `{event, actions}` и может дёрнуть `actions.done`
|
||
POST-ом без авторизации.
|
||
|
||
## Прод
|
||
|
||
`core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy —
|
||
`deploy/Caddyfile.snippet` в хаб sekai.
|
||
~~~
|
||
|
||
- [ ] **Step 2: Smoke test in Docker**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && docker compose up -d --build && sleep 15 \
|
||
&& docker compose exec app php artisan migrate --force \
|
||
&& docker compose exec app php artisan hado:source:create docs \
|
||
&& curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up
|
||
```
|
||
Expected: migration output, a `Token: hado_…` line, and `200`. Then, using that token:
|
||
|
||
```bash
|
||
TOKEN=<paste>
|
||
curl -s -X PUT localhost:8080/api/events -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||
-d '{"events":[{"user":"nikita","source_ref":"person:42","topic":"birthday:2026","due_at":"2026-09-04","fire_at":"2026-09-04","after_due":"expire","payload":{"title":"Тест"}}]}'
|
||
curl -s localhost:8080/me -H 'X-Remote-User: nikita'
|
||
docker compose logs scheduler --tail 5
|
||
```
|
||
Expected: JSON with `"state":"today"` (if run during the day in the default tz), `/me` shows `"badge":1`, scheduler log shows `hado:tick` running every minute.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/ai-projects/hado && git add -A && git commit -m "chore: docker compose, dockerfiles, caddy snippet, readme
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||
```
|
||
|
||
---
|
||
|
||
## Spec coverage check
|
||
|
||
| Spec section | Task |
|
||
|---|---|
|
||
| §4 model, UNIQUEs, meta deviation | 2 |
|
||
| §5 due_at forms, validation, upsert rules, recurring rows, retention, DELETE/GET | 5, 6, 9 |
|
||
| §6 states, transitions, eve rule, recompute on upsert | 3, 4, 5, 9 |
|
||
| §7 cascade | 3 |
|
||
| §8 windows, truncated window fallback, quiet rule, ack/done paths | 4, 7, 10 |
|
||
| §9 drivers, presence table, web heartbeat rule, tick algorithm, pending row, retries | 8, 9, 12 |
|
||
| §10 `/me` API, inbox, tz/quiet hours, channels, telegram link, manual events, archive | 7, 10, 11, 12 |
|
||
| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 13 |
|
||
| §12 compose, scheduler, worker, env | 13 |
|
||
| §13 tests | every task |
|
||
|
||
Known simplification vs spec: presence probes run sequentially per user (each cached 60 s) instead of in parallel; only `webhook` does network I/O, so the difference is at most one 2-second timeout per user per minute. Note it in the spec §9 if it ever matters.
|
||
|