diff --git a/docs/superpowers/plans/2026-09-04-hado-core.md b/docs/superpowers/plans/2026-09-04-hado-core.md new file mode 100644 index 0000000..15273aa --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-hado-core.md @@ -0,0 +1,5264 @@ +# 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 + 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 + 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 +``` + +--- + +### 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 ``, ensure these exist (Laravel ships most; set the DB and cache ones exactly): + +```xml + + + + + + + + + + + +``` + +- [ ] **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 +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`; `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 + $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 + */ + 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 +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 +hasMany(Event::class); + } +} +``` + +`core/app/Models/User.php` (replace the shipped file entirely): +```php +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 + ChannelType::class, + 'config' => 'array', + 'enabled' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} +``` + +`core/app/Models/Event.php`: +```php + 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 + '{}']; + + 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 + 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 +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` halving points, ascending + +- [ ] **Step 1: Failing tests** + +`core/tests/Unit/Scheduling/CascadeTest.php`: +```php + (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 + '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 +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 + + */ + 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 +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 +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 +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 +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 +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 + $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 + $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 +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` — `$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 +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 + '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 +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 колонки 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 + 23 || $i > 59 || $s > 59) { + throw new InvalidArgumentException("due_at: невалидное время «{$raw}»"); + } + } +} +``` + +`core/app/Ingest/EventUpserter.php`: +```php + $items + * @return list + */ + 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 +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 +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 + '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 +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 +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 + ['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> */ + 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 +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 */ + 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 +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 +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 + +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 + '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 +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 +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 +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 */ + 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 +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 +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 +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 + '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 + '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 + 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 + служебное канала, ляжет в 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 + $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 +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 +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 */ + 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 +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 +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 + 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 + $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 +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 + '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 + 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 + */ + 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 +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 +run(); + + return self::SUCCESS; + } +} +``` + +`core/app/Console/Commands/PurgeCommand.php`: +```php + $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 +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 +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 ` 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 + '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 + 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 + '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 +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 +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 +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 */ + 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 +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 +Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw" +``` + +--- + +### Task 11: Web inbox page + +**Files:** +- Create: `core/app/Http/Controllers/InboxController.php`, `core/resources/views/inbox.blade.php` +- 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/*` JSON endpoints (the page is plain HTML + vanilla JS calling them with `X-CSRF-TOKEN`). +- Produces: `GET /` renders groups `today`, `overdue`, `preparing`, `scheduled`; buttons «Помню» / done_label; settings block (tz, quiet hours, channels, Telegram link). Heartbeat every 30 s only when `document.visibilityState === 'visible'` and pointer/keyboard activity within 3 minutes. + +- [ ] **Step 1: Failing test** + +`core/tests/Feature/InboxPageTest.php`: +```php +get('/')->assertStatus(401); + } + + public function test_renders_groups_and_buttons(): void + { + $this->get('/', ['X-Remote-User' => 'nikita']); + $user = User::sole(); + $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + $make = fn (string $topic, EventState $state, ?string $doneLabel = null) => Event::create([ + 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $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' => array_filter(['title' => "Событие $topic", 'deep_link' => '/x', 'done_label' => $doneLabel]), + 'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), + ]); + $make('a', EventState::Today, 'Поздравил'); + $make('b', EventState::Overdue); + $make('c', EventState::Preparing); + $make('d', EventState::Scheduled); + $make('e', EventState::Done); + + $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); + + $this->assertStringContainsString('Событие a', $html); + $this->assertStringContainsString('Событие d', $html); + $this->assertStringNotContainsString('Событие e', $html); + $this->assertStringContainsString('Поздравил', $html); + $this->assertStringContainsString('Помню', $html); + $this->assertStringContainsString('Сделано', $html); + $this->assertStringContainsString('/me/heartbeat', $html); + $this->assertStringContainsString('visibilityState', $html); + $this->assertStringContainsString('csrf-token', $html); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd core && php artisan test --filter InboxPageTest +``` +Expected: FAIL (`/` renders the Laravel welcome page or 404). + +- [ ] **Step 3: Implement** + +`core/app/Http/Controllers/InboxController.php`: +```php +attributes->get('user'); + + $events = $user->events()->with('source') + ->whereIn('state', EventState::nonTerminalValues()) + ->orderBy('due_date')->orderBy('due_instant')->get() + ->map(fn (Event $e) => Presenter::present($e) + ['source' => $e->source->name]); + + $groups = []; + foreach (['today' => 'Сегодня', 'overdue' => 'Просрочено', 'preparing' => 'Грядёт', 'scheduled' => 'Запланировано'] as $state => $label) { + $groups[] = ['state' => $state, 'label' => $label, 'events' => $events->where('state', $state)->values()->all()]; + } + + return view('inbox', [ + 'profile' => ProfileController::present($user), + 'groups' => $groups, + 'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(), + 'timezones' => \DateTimeZone::listIdentifiers(), + ]); + } +} +``` + +`core/resources/views/inbox.blade.php`: +```blade + + + + + + +Hado · {{ $profile['badge'] > 0 ? '('.$profile['badge'].') ' : '' }}уведомления + + + +
+

Hado @if($profile['badge'] > 0){{ $profile['badge'] }}@endif

+ + @foreach($groups as $group) +

{{ $group['label'] }}

+ @forelse($group['events'] as $event) +
+
+
+ @if(!empty($event['payload']['deep_link'])){{ $event['payload']['title'] }}@else{{ $event['payload']['title'] }}@endif +
+ @if(!empty($event['payload']['subtitle']))
{{ $event['payload']['subtitle'] }}
@endif +
{{ $event['source'] }} · срок {{ $event['due_at'] }}
+
+ @if($group['state'] !== 'scheduled') + + @endif + +
+ @empty +
Пусто
+ @endforelse + @endforeach + +
+ Настройки · {{ $profile['login'] }} + +
+ + + + +
+ +

Каналы

+
    + @foreach($channels as $channel) +
  • + {{ $channel['type'] }} + {{ $channel['type'] === 'webhook' ? $channel['config']['deliver_url'] : ($channel['type'] === 'telegram' ? 'chat '.$channel['config']['chat_id'] : 'этот инбокс') }} + @if($channel['type'] !== 'web')@endif +
  • + @endforeach +
+ +
+ + + +
+ +
+ + +
+
+
+ + + + +``` + +Add to the `remote.user` group in `core/routes/web.php`: +```php +Route::get('/', \App\Http\Controllers\InboxController::class); +``` +and delete `core/resources/views/welcome.blade.php`. + +- [ ] **Step 4: Run tests** + +```bash +cd core && php artisan test --filter InboxPageTest +``` +Expected: 2 tests PASS. + +- [ ] **Step 5: Run the whole suite and Pint** + +```bash +cd core && php artisan test && vendor/bin/pint --test +``` +Expected: all green; if Pint reports style issues run `vendor/bin/pint` and re-run tests. + +- [ ] **Step 6: Commit** + +```bash +cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox with presence heartbeat + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw" +``` + +--- + +### Task 12: 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 :8080 + } + handle { + import protected + reverse_proxy :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/setWebhook?url=https://hado.<домен>/hooks/telegram&secret_token="` +3. В инбоксе «Подключить Telegram» → отправь боту `/start <код>`. + +## Home Assistant + +В инбоксе добавь webhook-канал: `deliver_url` — HA webhook-триггер +(`https://ha/api/webhook/`), `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= +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 +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, 11 | +| §10 `/me` API, inbox, tz/quiet hours, channels, telegram link | 7, 10, 11 | +| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 12 | +| §12 compose, scheduler, worker, env | 12 | +| §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. +