# 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.