Files
hado/docs/superpowers/plans/2026-09-04-hado-core.md

218 KiB
Raw Blame History

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 DeliverJobs through ChannelDriver implementations. HTTP has three entrances with three auth mechanisms: source Bearer tokens (/api/*), X-Remote-User from the sekai hub (/, /me/*), and per-delivery action tokens (/a/*, /hooks/*).

Tech Stack: PHP 8.4 (Docker) / 8.5 (local), Laravel 13, PHPUnit 12, Postgres 17 (prod), SQLite in-memory (tests), Laravel scheduler + database queue, Telegram Bot API, Docker Compose.

Spec: docs/superpowers/specs/2026-09-03-hado-core-design.md

Global Constraints

  • Core knows no domains: no birthday/passport logic anywhere in core/. Only title, subtitle, deep_link, done_label in payload.
  • Event identity is (source_id, source_ref, topic), UNIQUE. Recurring events use a period in topic (birthday:2026); the core never infers recurrence.
  • due_mode is an explicit enum local | fixed; code branches on it, never on a null column.
  • fire_on is always set; no preparation means fire_on = due day.
  • after_due enum keep | expire.
  • States: scheduled, preparing, today, overdue, done, expired, withdrawn. Badge = count of today + overdue.
  • Active hours per user: [quiet_end, quiet_start), defaults 09:00 / 22:00. Constraint: quiet_end < quiet_start (quiet hours cross midnight).
  • Windows: preparing/overdue one per day; today hourly. Fallback margins HADO_FALLBACK_DAY=30, HADO_FALLBACK_HOUR=10 minutes; today fallback only in hours HADO_FALLBACK_HOURS=9,14,20 or in a window truncated by the due moment.
  • One delivery row per (event_id, channel_id, window_start), inserted as pending before the job is dispatched.
  • Presence cache TTL 60 s; presence HTTP timeout HADO_PRESENCE_TIMEOUT=2 s.
  • Terminal events purged after HADO_RETENTION_DAYS=90.
  • All user-facing strings in Russian: «Помню», «Сделано», «Подключено».
  • Tests: PHPUnit, RefreshDatabase, time via CarbonImmutable::setTestNow, HTTP via Http::fake(). Run with cd core && php artisan test.
  • Commit after every task with a feat:/test:/chore: prefix. Commit trailer:
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
    
  • Spec deviation, accepted: DB "enums" are string columns backed by PHP enums (portable to SQLite for tests); deliveries gains a meta json column for channel bookkeeping (Telegram message_id). Task 2 patches the spec accordingly.

File Structure

core/                                   Laravel app (created in Task 1)
  app/Enums/DueMode.php                 local | fixed
  app/Enums/AfterDue.php                keep | expire
  app/Enums/EventState.php              7 states + isTerminal/isActive/countsInBadge
  app/Enums/ChannelType.php             web | telegram | webhook
  app/Enums/DeliveryResult.php          pending | ok | failed
  app/Enums/Presence.php                present | absent | unknown
  app/Models/{Source,User,Channel,Event,Delivery}.php
  app/Scheduling/Due.php                value: day + moment (local)
  app/Scheduling/DueResolver.php        Event+User → Due, todayStart, fireStart
  app/Scheduling/Cascade.php            halving points
  app/Scheduling/StateResolver.php      dates + now → EventState
  app/Scheduling/Window.php             value: start, end, fallbackAllowed, fallbackAt
  app/Scheduling/WindowResolver.php     state + now → ?Window
  app/Scheduling/QuietRule.php          «Помню» → quiet_until
  app/Ingest/DueAtParser.php            "2026-08-14" | "…T14:00" | "…T08:15+02:00" → ParsedDue
  app/Ingest/ParsedDue.php              value object
  app/Ingest/EventUpserter.php          upsert rules from spec §5
  app/Channels/ChannelDriver.php        interface: presence, deliver, onDone
  app/Channels/DeliveryOutcome.php      ok | failed(error)
  app/Channels/ChannelRegistry.php      ChannelType → driver
  app/Channels/WebChannel.php
  app/Channels/WebhookChannel.php
  app/Channels/TelegramChannel.php
  app/Delivery/Tick.php                 per-minute planner
  app/Delivery/DeliverJob.php           queued delivery with retries
  app/Delivery/EventActions.php         ack / done
  app/Http/Middleware/AuthenticateSource.php
  app/Http/Middleware/RemoteUser.php
  app/Http/Requests/UpsertEventsRequest.php
  app/Http/Controllers/Api/EventsController.php
  app/Http/Controllers/Me/{ProfileController,EventsController,ChannelsController}.php
  app/Http/Controllers/ActionController.php
  app/Http/Controllers/TelegramWebhookController.php
  app/Http/Controllers/InboxController.php   (design: docs/design/handoff)
  public/inbox.js
  app/Console/Commands/{SourceCreate,TickCommand,PurgeCommand}.php
  config/hado.php
  database/migrations/0001_01_01_000000_create_hado_tables.php
  resources/views/inbox.blade.php
  routes/{api,web,console}.php
  tests/Unit/Scheduling/*, tests/Unit/Ingest/*, tests/Feature/*
docker-compose.yml, core/Dockerfile, core/Dockerfile.dev, deploy/Caddyfile.snippet, README.md
docs/design/handoff/                    дизайн-хендофф (прототип инбокса, токены, README)

Task 1: Scaffold the Laravel app and local toolchain

Files:

  • Create: core/ (Laravel 13 skeleton via composer)
  • Modify: core/phpunit.xml, core/.env.example, C:\php-8.5.4\php.ini (enable extensions)
  • Create: .gitignore (root)

Interfaces:

  • Produces: a runnable Laravel app where php artisan test passes on SQLite in-memory.

  • Step 1: Enable SQLite/intl/pgsql extensions in local PHP

The dlls exist in C:\php-8.5.4\ext, the lines are commented out. Uncomment them:

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
cd /d/ai-projects/hado
composer create-project laravel/laravel core --no-interaction
cd core && php artisan --version

Expected: Laravel Framework 13.x.y.

  • Step 3: Configure test environment

In core/phpunit.xml inside <php>, ensure these exist (Laravel ships most; set the DB and cache ones exactly):

<env name="APP_ENV" value="testing"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="APP_URL" value="http://hado.test"/>
<env name="HADO_DEFAULT_TZ" value="UTC"/>
<env name="TELEGRAM_BOT_TOKEN" value="test-token"/>
<env name="TELEGRAM_BOT_USERNAME" value="hado_test_bot"/>
<env name="TELEGRAM_WEBHOOK_SECRET" value="test-secret"/>
  • Step 4: Set .env.example for Postgres + database queue/cache

Replace the DB/queue/cache section of core/.env.example with:

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
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
cd core && php artisan test

Expected: No tests executed! or 0 tests, exit code 0.

  • Step 7: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "chore: scaffold Laravel 13 core app

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 2: Enums, migration, models

Files:

  • Create: core/app/Enums/{DueMode,AfterDue,EventState,ChannelType,DeliveryResult,Presence}.php
  • Delete: core/database/migrations/0001_01_01_000000_create_users_table.php
  • Create: core/database/migrations/0001_01_01_000000_create_hado_tables.php
  • Replace: core/app/Models/User.php; Create: core/app/Models/{Source,Channel,Event,Delivery}.php
  • Create: core/config/hado.php
  • Test: core/tests/Feature/SchemaTest.php
  • Modify: docs/superpowers/specs/2026-09-03-hado-core-design.md (deliveries.meta, string-backed enums)

Interfaces:

  • Produces: EventState::isTerminal(): bool, isActive(): bool (preparing/today/overdue), countsInBadge(): bool, static nonTerminalValues(): array<string>; User::now(): CarbonImmutable, User::channels(); Event casts (due_date/fire_onCarbonImmutable date, due_instant/quiet_until/done_atCarbonImmutable, payload → array, enums); Event::isTerminal(); Delivery with meta array.

  • Step 1: Write the failing schema test

core/tests/Feature/SchemaTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Channel;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SchemaTest extends TestCase
{
    use RefreshDatabase;

    private function makeEvent(Source $source, User $user, string $topic = 'birthday:2026'): Event
    {
        return Event::create([
            'source_id' => $source->id,
            'user_id' => $user->id,
            'source_ref' => 'person:42',
            'topic' => $topic,
            'due_mode' => DueMode::Local,
            'due_date' => '2026-08-14',
            'due_time' => '24:00:00',
            'fire_on' => '2026-08-14',
            'after_due' => AfterDue::Expire,
            'payload' => ['title' => 'ДР'],
            'state' => EventState::Scheduled,
            'quiet_until' => CarbonImmutable::parse('2000-01-01T00:00:00Z'),
        ]);
    }

    public function test_event_identity_is_unique_per_source_ref_topic(): void
    {
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $this->makeEvent($source, $user);

        $this->expectException(QueryException::class);
        $this->makeEvent($source, $user);
    }

    public function test_same_source_ref_with_different_topics_coexist(): void
    {
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $this->makeEvent($source, $user, 'birthday:2026');
        $this->makeEvent($source, $user, 'birthday:2027');

        $this->assertSame(2, Event::count());
    }

    public function test_casts_round_trip(): void
    {
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $user = User::create(['login' => 'nikita', 'tz' => 'Asia/Shanghai', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $event = $this->makeEvent($source, $user)->fresh();

        $this->assertSame(DueMode::Local, $event->due_mode);
        $this->assertSame(EventState::Scheduled, $event->state);
        $this->assertSame('2026-08-14', $event->due_date->format('Y-m-d'));
        $this->assertSame('24:00:00', $event->due_time);
        $this->assertSame(['title' => 'ДР'], $event->payload);
        $this->assertFalse($event->isTerminal());
        $this->assertSame('Asia/Shanghai', $user->now()->timezoneName);

        Channel::create(['user_id' => $user->id, 'type' => ChannelType::Web, 'config' => [], 'enabled' => true]);
        $this->assertSame(ChannelType::Web, $user->channels()->first()->type);
    }

    public function test_state_helpers(): void
    {
        $this->assertTrue(EventState::Done->isTerminal());
        $this->assertTrue(EventState::Expired->isTerminal());
        $this->assertTrue(EventState::Withdrawn->isTerminal());
        $this->assertFalse(EventState::Today->isTerminal());
        $this->assertTrue(EventState::Preparing->isActive());
        $this->assertFalse(EventState::Scheduled->isActive());
        $this->assertTrue(EventState::Today->countsInBadge());
        $this->assertTrue(EventState::Overdue->countsInBadge());
        $this->assertFalse(EventState::Preparing->countsInBadge());
        $this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues());
    }
}
  • Step 2: Run to verify it fails
cd core && php artisan test --filter SchemaTest

Expected: FAIL, Class "App\Enums\DueMode" not found.

  • Step 3: Enums

core/app/Enums/DueMode.php:

<?php

namespace App\Enums;

enum DueMode: string
{
    case Local = 'local';
    case Fixed = 'fixed';
}

core/app/Enums/AfterDue.php:

<?php

namespace App\Enums;

enum AfterDue: string
{
    case Keep = 'keep';
    case Expire = 'expire';
}

core/app/Enums/EventState.php:

<?php

namespace App\Enums;

enum EventState: string
{
    case Scheduled = 'scheduled';
    case Preparing = 'preparing';
    case Today = 'today';
    case Overdue = 'overdue';
    case Done = 'done';
    case Expired = 'expired';
    case Withdrawn = 'withdrawn';

    public function isTerminal(): bool
    {
        return in_array($this, [self::Done, self::Expired, self::Withdrawn], true);
    }

    /** Состояния, в которых ядро напоминает. */
    public function isActive(): bool
    {
        return in_array($this, [self::Preparing, self::Today, self::Overdue], true);
    }

    public function countsInBadge(): bool
    {
        return in_array($this, [self::Today, self::Overdue], true);
    }

    /** @return list<string> */
    public static function nonTerminalValues(): array
    {
        return array_values(array_map(
            fn (self $s) => $s->value,
            array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()),
        ));
    }
}

core/app/Enums/ChannelType.php:

<?php

namespace App\Enums;

enum ChannelType: string
{
    case Web = 'web';
    case Telegram = 'telegram';
    case Webhook = 'webhook';
}

core/app/Enums/DeliveryResult.php:

<?php

namespace App\Enums;

enum DeliveryResult: string
{
    case Pending = 'pending';
    case Ok = 'ok';
    case Failed = 'failed';
}

core/app/Enums/Presence.php:

<?php

namespace App\Enums;

enum Presence: string
{
    case Present = 'present';
    case Absent = 'absent';
    case Unknown = 'unknown';
}
  • Step 4: Migration

Delete the default users migration and add ours. Keep the shipped *_create_cache_table.php and *_create_jobs_table.php.

rm core/database/migrations/0001_01_01_000000_create_users_table.php
rm core/database/factories/UserFactory.php

And empty the seeder body so nothing references the old auth user: in core/database/seeders/DatabaseSeeder.php make run() contain only //.

core/database/migrations/0001_01_01_000000_create_hado_tables.php:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sources', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->string('token_hash', 64)->unique();
            $table->timestamps();
        });

        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('login')->unique();
            $table->string('tz', 64);
            $table->time('quiet_start')->default('22:00:00');
            $table->time('quiet_end')->default('09:00:00');
            $table->timestamps();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });

        Schema::create('channels', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('type', 16);
            $table->json('config');
            $table->boolean('enabled')->default(true);
            $table->timestamps();
            $table->index(['user_id', 'type']);
        });

        Schema::create('events', function (Blueprint $table) {
            $table->id();
            $table->foreignId('source_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('source_ref');
            $table->string('topic')->default('');
            $table->string('due_mode', 8);
            $table->date('due_date')->nullable();          // при local
            $table->time('due_time')->nullable();          // при local; 24:00:00 = весь день
            $table->timestampTz('due_instant')->nullable(); // при fixed
            $table->date('fire_on');
            $table->string('after_due', 8);
            $table->json('payload');
            $table->string('state', 16)->index();
            $table->timestampTz('quiet_until');
            $table->timestampTz('done_at')->nullable();
            $table->timestamps();
            $table->unique(['source_id', 'source_ref', 'topic']);
            $table->index(['user_id', 'state']);
        });

        Schema::create('deliveries', function (Blueprint $table) {
            $table->id();
            $table->foreignId('event_id')->constrained()->cascadeOnDelete();
            $table->foreignId('channel_id')->constrained()->cascadeOnDelete();
            $table->timestampTz('window_start');
            $table->string('action_token', 64)->unique();
            $table->string('result', 8)->default('pending');
            $table->timestampTz('sent_at')->nullable();
            $table->text('error')->nullable();
            $table->json('meta');
            $table->timestamps();
            $table->unique(['event_id', 'channel_id', 'window_start']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('deliveries');
        Schema::dropIfExists('events');
        Schema::dropIfExists('channels');
        Schema::dropIfExists('sessions');
        Schema::dropIfExists('users');
        Schema::dropIfExists('sources');
    }
};
  • Step 5: Models

core/app/Models/Source.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Source extends Model
{
    protected $guarded = [];

    public function events(): HasMany
    {
        return $this->hasMany(Event::class);
    }
}

core/app/Models/User.php (replace the shipped file entirely):

<?php

namespace App\Models;

use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Model
{
    protected $guarded = [];

    public function channels(): HasMany
    {
        return $this->hasMany(Channel::class);
    }

    public function events(): HasMany
    {
        return $this->hasMany(Event::class);
    }

    /** «Сейчас» в поясе пользователя. */
    public function now(): CarbonImmutable
    {
        return CarbonImmutable::now($this->tz);
    }

    /** Начало активных часов в данный календарный день. */
    public function activeStart(CarbonImmutable $day): CarbonImmutable
    {
        return $day->startOfDay()->setTimeFromTimeString($this->quiet_end);
    }

    /** Конец активных часов (начало тихих) в данный календарный день. */
    public function activeEnd(CarbonImmutable $day): CarbonImmutable
    {
        return $day->startOfDay()->setTimeFromTimeString($this->quiet_start);
    }
}

core/app/Models/Channel.php:

<?php

namespace App\Models;

use App\Enums\ChannelType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Channel extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'type' => ChannelType::class,
            'config' => 'array',
            'enabled' => 'boolean',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

core/app/Models/Event.php:

<?php

namespace App\Models;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Event extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'due_mode' => DueMode::class,
            'after_due' => AfterDue::class,
            'state' => EventState::class,
            'due_date' => 'immutable_date',
            'fire_on' => 'immutable_date',
            'due_instant' => 'immutable_datetime',
            'quiet_until' => 'immutable_datetime',
            'done_at' => 'immutable_datetime',
            'payload' => 'array',
        ];
    }

    public function source(): BelongsTo
    {
        return $this->belongsTo(Source::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function deliveries(): HasMany
    {
        return $this->hasMany(Delivery::class);
    }

    public function isTerminal(): bool
    {
        return $this->state->isTerminal();
    }
}

core/app/Models/Delivery.php:

<?php

namespace App\Models;

use App\Enums\DeliveryResult;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Delivery extends Model
{
    protected $guarded = [];

    protected $attributes = ['meta' => '{}'];

    protected function casts(): array
    {
        return [
            'result' => DeliveryResult::class,
            'window_start' => 'immutable_datetime',
            'sent_at' => 'immutable_datetime',
            'meta' => 'array',
        ];
    }

    public function event(): BelongsTo
    {
        return $this->belongsTo(Event::class);
    }

    public function channel(): BelongsTo
    {
        return $this->belongsTo(Channel::class);
    }
}
  • Step 6: Config

core/config/hado.php:

<?php

return [
    'default_tz' => env('HADO_DEFAULT_TZ', 'UTC'),
    'presence_timeout' => (int) env('HADO_PRESENCE_TIMEOUT', 2),
    'presence_ttl' => 60,
    'fallback_day_minutes' => (int) env('HADO_FALLBACK_DAY', 30),
    'fallback_hour_minutes' => (int) env('HADO_FALLBACK_HOUR', 10),
    'fallback_hours' => array_map('intval', explode(',', env('HADO_FALLBACK_HOURS', '9,14,20'))),
    'retention_days' => (int) env('HADO_RETENTION_DAYS', 90),
    'telegram' => [
        'token' => env('TELEGRAM_BOT_TOKEN', ''),
        'username' => env('TELEGRAM_BOT_USERNAME', ''),
        'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''),
        'seen_ttl' => 600,
    ],
    'web_heartbeat_ttl' => 90,
];
  • Step 7: Run tests
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
cd /d/ai-projects/hado && git add -A && git commit -m "feat: enums, schema and models for hado core

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 3: DueResolver and Cascade (pure scheduling math)

Files:

  • Create: core/app/Scheduling/Due.php, core/app/Scheduling/DueResolver.php, core/app/Scheduling/Cascade.php
  • Test: core/tests/Unit/Scheduling/DueResolverTest.php, core/tests/Unit/Scheduling/CascadeTest.php

Interfaces:

  • Consumes: Event, User models (in-memory, no DB).

  • Produces:

    • final class Due { CarbonImmutable $day; CarbonImmutable $moment; } both in the user's tz; day is local midnight of the due day; moment is the due moment (for a whole-day event: next midnight).
    • DueResolver::resolve(Event, User): Due
    • DueResolver::fireStart(Event, User): CarbonImmutable local midnight of fire_on
    • DueResolver::todayStart(Due, User): CarbonImmutable local midnight of due day, or quiet_start 2h the day before when moment <= activeStart(day)
    • Cascade::points(CarbonImmutable $fireStart, CarbonImmutable $dueDay): list<CarbonImmutable> halving points, ascending
  • Step 1: Failing tests

core/tests/Unit/Scheduling/CascadeTest.php:

<?php

namespace Tests\Unit\Scheduling;

use App\Scheduling\Cascade;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory

class CascadeTest extends TestCase
{
    private function daysBefore(array $points, CarbonImmutable $due): array
    {
        return array_map(fn (CarbonImmutable $p) => (int) round($p->diffInDays($due)), $points);
    }

    public function test_no_preparation_gives_no_points(): void
    {
        $due = CarbonImmutable::parse('2026-08-14', 'UTC');
        $this->assertSame([], Cascade::points($due, $due));
    }

    public function test_halving_points(): void
    {
        $due = CarbonImmutable::parse('2026-09-30', 'UTC');

        $this->assertSame([1], $this->daysBefore(Cascade::points($due->subDay(), $due), $due));
        $this->assertSame([7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(7), $due), $due));
        $this->assertSame([30, 15, 7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(30), $due), $due));
        $this->assertSame([60, 30, 15, 7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(60), $due), $due));
        $this->assertSame([180, 90, 45, 22, 11, 5, 2, 1], $this->daysBefore(Cascade::points($due->subDays(180), $due), $due));
    }

    public function test_points_are_local_midnights_ascending(): void
    {
        $due = CarbonImmutable::parse('2026-09-30', 'Asia/Shanghai');
        $points = Cascade::points($due->subDays(7), $due);

        $this->assertSame('2026-09-23 00:00 Asia/Shanghai', $points[0]->format('Y-m-d H:i e'));
        $this->assertSame('2026-09-29 00:00 Asia/Shanghai', $points[2]->format('Y-m-d H:i e'));
    }
}

core/tests/Unit/Scheduling/DueResolverTest.php:

<?php

namespace Tests\Unit\Scheduling;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory

class DueResolverTest extends TestCase
{
    private function user(string $tz = 'America/Montevideo'): User
    {
        return new User(['login' => 'n', 'tz' => $tz, 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
    }

    private function local(string $date, string $time = '24:00:00', ?string $fireOn = null): Event
    {
        return new Event([
            'due_mode' => DueMode::Local, 'due_date' => $date, 'due_time' => $time,
            'fire_on' => $fireOn ?? $date, 'after_due' => AfterDue::Expire,
            'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
        ]);
    }

    private function fixed(string $instant, string $fireOn): Event
    {
        return new Event([
            'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse($instant),
            'fire_on' => $fireOn, 'after_due' => AfterDue::Expire,
            'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
        ]);
    }

    public function test_whole_day_local_event(): void
    {
        $due = (new DueResolver)->resolve($this->local('2026-08-14'), $this->user());

        $this->assertSame('2026-08-14 00:00 America/Montevideo', $due->day->format('Y-m-d H:i e'));
        $this->assertSame('2026-08-15 00:00 America/Montevideo', $due->moment->format('Y-m-d H:i e'));
    }

    public function test_timed_local_event(): void
    {
        $due = (new DueResolver)->resolve($this->local('2026-09-10', '14:00:00'), $this->user());

        $this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i'));
        $this->assertSame('2026-09-10 14:00', $due->moment->format('Y-m-d H:i'));
    }

    public function test_local_event_follows_user_timezone(): void
    {
        $event = $this->local('2026-09-10', '14:00:00');

        $uy = (new DueResolver)->resolve($event, $this->user('America/Montevideo'));
        $cn = (new DueResolver)->resolve($event, $this->user('Asia/Shanghai'));

        $this->assertSame('2026-09-10 14:00', $uy->moment->format('Y-m-d H:i'));
        $this->assertSame('2026-09-10 14:00', $cn->moment->format('Y-m-d H:i'));
        $this->assertNotEquals($uy->moment->utc()->toIso8601String(), $cn->moment->utc()->toIso8601String());
    }

    public function test_fixed_event_is_converted_into_user_timezone(): void
    {
        // Вылет из Мадрида 08:15+02:00 = 06:15Z = 03:15 в Монтевидео (-03:00)
        $due = (new DueResolver)->resolve($this->fixed('2026-09-10T08:15:00+02:00', '2026-09-03'), $this->user());

        $this->assertSame('2026-09-10 03:15 America/Montevideo', $due->moment->format('Y-m-d H:i e'));
        $this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i'));
    }

    public function test_fire_start_is_local_midnight(): void
    {
        $start = (new DueResolver)->fireStart($this->local('2026-09-30', '24:00:00', '2026-08-31'), $this->user());
        $this->assertSame('2026-08-31 00:00 America/Montevideo', $start->format('Y-m-d H:i e'));
    }

    public function test_today_starts_at_midnight_for_normal_moment(): void
    {
        $r = new DueResolver;
        $due = $r->resolve($this->local('2026-09-10', '14:00:00'), $this->user());
        $this->assertSame('2026-09-10 00:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i'));
    }

    public function test_today_starts_the_evening_before_for_early_moment(): void
    {
        $r = new DueResolver;
        $due = $r->resolve($this->local('2026-09-10', '07:00:00'), $this->user());
        // quiet_start 22:00  2h = 20:00 накануне
        $this->assertSame('2026-09-09 20:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i'));

        $exactlyAtActiveStart = $r->resolve($this->local('2026-09-10', '09:00:00'), $this->user());
        $this->assertSame('2026-09-09 20:00', $r->todayStart($exactlyAtActiveStart, $this->user())->format('Y-m-d H:i'));
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter "CascadeTest|DueResolverTest"

Expected: FAIL, class not found.

  • Step 3: Implement

core/app/Scheduling/Due.php:

<?php

namespace App\Scheduling;

use Carbon\CarbonImmutable;

/** Срок события, приведённый к поясу пользователя. */
final class Due
{
    public function __construct(
        /** Локальная полночь дня срока. */
        public readonly CarbonImmutable $day,
        /** Момент срока. Для события «весь день» — следующая полночь. */
        public readonly CarbonImmutable $moment,
    ) {}
}

core/app/Scheduling/DueResolver.php:

<?php

namespace App\Scheduling;

use App\Enums\DueMode;
use App\Models\Event;
use App\Models\User;
use Carbon\CarbonImmutable;

final class DueResolver
{
    public const WHOLE_DAY = '24:00:00';

    public function resolve(Event $event, User $user): Due
    {
        return match ($event->due_mode) {
            DueMode::Fixed => $this->fixed($event, $user),
            DueMode::Local => $this->local($event, $user),
        };
    }

    public function fireStart(Event $event, User $user): CarbonImmutable
    {
        return self::localMidnight($event->fire_on->format('Y-m-d'), $user->tz);
    }

    /**
     * Старт дня срока. Обычно локальная полночь дня срока. Если момент срока не
     * позже начала активных часов, первое окно дня уже опоздало бы — тогда старт
     * за два часа до тихих часов накануне (два последних часовых окна вечера).
     */
    public function todayStart(Due $due, User $user): CarbonImmutable
    {
        if ($due->moment->lte($user->activeStart($due->day))) {
            return $user->activeEnd($due->day->subDay())->subHours(2);
        }

        return $due->day;
    }

    public static function localMidnight(string $ymd, string $tz): CarbonImmutable
    {
        return CarbonImmutable::createFromFormat('Y-m-d', $ymd, $tz)->startOfDay();
    }

    private function local(Event $event, User $user): Due
    {
        $day = self::localMidnight($event->due_date->format('Y-m-d'), $user->tz);
        $moment = $event->due_time === self::WHOLE_DAY
            ? $day->addDay()
            : $day->setTimeFromTimeString($event->due_time);

        return new Due($day, $moment);
    }

    private function fixed(Event $event, User $user): Due
    {
        $moment = $event->due_instant->setTimezone($user->tz);

        return new Due($moment->startOfDay(), $moment);
    }
}

core/app/Scheduling/Cascade.php:

<?php

namespace App\Scheduling;

use Carbon\CarbonImmutable;

final class Cascade
{
    /**
     * Точки каскада между стартом и днём срока: старт, затем каждый раз, когда
     * до срока остаётся вдвое меньше дней, до одного дня. Обе даты — локальные
     * полуночи в одном поясе. Возвращает локальные полуночи по возрастанию.
     *
     * @return list<CarbonImmutable>
     */
    public static function points(CarbonImmutable $fireStart, CarbonImmutable $dueDay): array
    {
        $d = (int) round($fireStart->diffInDays($dueDay));
        $points = [];
        while ($d >= 1) {
            $points[] = $dueDay->subDays($d);
            $d = intdiv($d, 2);
        }

        return $points;
    }
}
  • Step 4: Run tests
cd core && php artisan test --filter "CascadeTest|DueResolverTest"

Expected: 10 tests PASS.

  • Step 5: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: due resolution and cascade math

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 4: StateResolver, WindowResolver, QuietRule

Files:

  • Create: core/app/Scheduling/StateResolver.php, core/app/Scheduling/Window.php, core/app/Scheduling/WindowResolver.php, core/app/Scheduling/QuietRule.php
  • Modify: core/app/Providers/AppServiceProvider.php (bind WindowResolver from config)
  • Test: core/tests/Unit/Scheduling/StateResolverTest.php, WindowResolverTest.php, QuietRuleTest.php

Interfaces:

  • Consumes: DueResolver, Cascade, Due, User::activeStart/activeEnd.

  • Produces:

    • StateResolver::resolve(Event, User, CarbonImmutable $now): EventState — non-terminal state implied by dates. Never returns Done/Withdrawn.
    • final class Window { CarbonImmutable $start; CarbonImmutable $end; bool $fallbackAllowed; CarbonImmutable $fallbackAt; }
    • WindowResolver::__construct(int $dayMarginMinutes, int $hourMarginMinutes, array $fallbackHours)
    • WindowResolver::current(EventState, User, Due, CarbonImmutable $now): ?Window
    • QuietRule::afterAck(Event, User, EventState, Due, CarbonImmutable $now): CarbonImmutable
  • Step 1: Failing tests

core/tests/Unit/Scheduling/StateResolverTest.php:

<?php

namespace Tests\Unit\Scheduling;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use App\Scheduling\StateResolver;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory

class StateResolverTest extends TestCase
{
    private User $user;
    private StateResolver $resolver;

    protected function setUp(): void
    {
        $this->user = new User(['login' => 'n', 'tz' => 'America/Montevideo', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $this->resolver = new StateResolver(new DueResolver);
    }

    private function event(string $due, string $fireOn, AfterDue $afterDue = AfterDue::Keep, string $time = '24:00:00'): Event
    {
        return new Event([
            'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn,
            'after_due' => $afterDue, 'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
        ]);
    }

    private function at(string $local): CarbonImmutable
    {
        return CarbonImmutable::parse($local, 'America/Montevideo');
    }

    public function test_deadline_walks_through_states(): void
    {
        $e = $this->event('2026-09-30', '2026-08-31', AfterDue::Keep);

        $this->assertSame(EventState::Scheduled, $this->resolver->resolve($e, $this->user, $this->at('2026-08-30 23:59')));
        $this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-08-31 00:00')));
        $this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-09-29 23:59')));
        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-30 00:00')));
        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-30 23:59')));
        $this->assertSame(EventState::Overdue, $this->resolver->resolve($e, $this->user, $this->at('2026-10-01 00:00')));
    }

    public function test_day_of_never_prepares_and_expires(): void
    {
        $e = $this->event('2026-08-14', '2026-08-14', AfterDue::Expire);

        $this->assertSame(EventState::Scheduled, $this->resolver->resolve($e, $this->user, $this->at('2026-08-13 23:59')));
        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-08-14 00:00')));
        $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-08-15 00:00')));
    }

    public function test_timed_moment_ends_today_mid_day(): void
    {
        $e = $this->event('2026-09-10', '2026-09-03', AfterDue::Expire, '14:00:00');

        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 13:59')));
        $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 14:00')));
    }

    public function test_early_moment_starts_today_the_evening_before(): void
    {
        $e = $this->event('2026-09-10', '2026-09-03', AfterDue::Expire, '07:00:00');

        $this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 19:59')));
        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 20:00')));
        $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 07:00')));
    }

    public function test_fixed_flight_uses_user_timezone(): void
    {
        $e = new Event([
            'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse('2026-09-10T08:15:00+02:00'),
            'fire_on' => '2026-09-03', 'after_due' => AfterDue::Expire, 'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
        ]);
        // 03:15 по Монтевидео → раньше активных часов → today с 20:00 накануне
        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 20:00')));
        $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 03:14')));
        $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 03:15')));
    }
}

core/tests/Unit/Scheduling/WindowResolverTest.php:

<?php

namespace Tests\Unit\Scheduling;

use App\Enums\EventState;
use App\Models\User;
use App\Scheduling\Due;
use App\Scheduling\WindowResolver;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory

class WindowResolverTest extends TestCase
{
    private User $user;
    private WindowResolver $windows;

    protected function setUp(): void
    {
        $this->user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $this->windows = new WindowResolver(30, 10, [9, 14, 20]);
    }

    private function at(string $s): CarbonImmutable
    {
        return CarbonImmutable::parse($s, 'UTC');
    }

    private function wholeDay(string $ymd): Due
    {
        $day = $this->at($ymd);
        return new Due($day, $day->addDay());
    }

    public function test_outside_active_hours_there_is_no_window(): void
    {
        $due = $this->wholeDay('2026-09-30');
        $this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 08:59')));
        $this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 22:00')));
        $this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 23:00')));
    }

    public function test_preparing_and_overdue_have_one_daily_window(): void
    {
        $due = $this->wholeDay('2026-09-30');
        foreach ([EventState::Preparing, EventState::Overdue] as $state) {
            $w = $this->windows->current($state, $this->user, $due, $this->at('2026-09-20 12:34'));
            $this->assertSame('2026-09-20 09:00', $w->start->format('Y-m-d H:i'));
            $this->assertSame('2026-09-20 22:00', $w->end->format('Y-m-d H:i'));
            $this->assertTrue($w->fallbackAllowed);
            $this->assertSame('2026-09-20 21:30', $w->fallbackAt->format('Y-m-d H:i'));
        }
    }

    public function test_today_has_hourly_windows_with_fallback_only_in_listed_hours(): void
    {
        $due = $this->wholeDay('2026-09-30');

        $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:20'));
        $this->assertSame('2026-09-30 09:00', $w->start->format('Y-m-d H:i'));
        $this->assertSame('2026-09-30 10:00', $w->end->format('Y-m-d H:i'));
        $this->assertTrue($w->fallbackAllowed);
        $this->assertSame('2026-09-30 09:50', $w->fallbackAt->format('Y-m-d H:i'));

        $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:05'));
        $this->assertSame('2026-09-30 11:00', $w->start->format('Y-m-d H:i'));
        $this->assertFalse($w->fallbackAllowed);

        $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 21:59'));
        $this->assertSame('2026-09-30 22:00', $w->end->format('Y-m-d H:i'));
    }

    public function test_today_window_is_truncated_by_due_moment_and_becomes_fallback(): void
    {
        $day = $this->at('2026-09-30');

        $due = new Due($day, $day->setTime(9, 30));
        $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:05'));
        $this->assertSame('2026-09-30 09:30', $w->end->format('Y-m-d H:i'));
        $this->assertSame('2026-09-30 09:20', $w->fallbackAt->format('Y-m-d H:i'));

        $due = new Due($day, $day->setTime(11, 15));
        $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:02'));
        $this->assertSame('2026-09-30 11:15', $w->end->format('Y-m-d H:i'));
        $this->assertTrue($w->fallbackAllowed, 'обрезанное окно всегда fallback-окно');
        $this->assertSame('2026-09-30 11:05', $w->fallbackAt->format('Y-m-d H:i'));
    }

    public function test_no_window_once_due_moment_passed(): void
    {
        $day = $this->at('2026-09-30');
        $due = new Due($day, $day->setTime(11, 15));
        $this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:20')));
    }
}

core/tests/Unit/Scheduling/QuietRuleTest.php:

<?php

namespace Tests\Unit\Scheduling;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use App\Scheduling\QuietRule;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory

class QuietRuleTest extends TestCase
{
    private User $user;
    private DueResolver $due;
    private QuietRule $rule;

    protected function setUp(): void
    {
        $this->user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $this->due = new DueResolver;
        $this->rule = new QuietRule($this->due);
    }

    private function event(string $due, string $fireOn, string $time = '24:00:00'): Event
    {
        return new Event([
            'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn,
            'after_due' => AfterDue::Keep, 'state' => EventState::Preparing, 'payload' => ['title' => 't'],
        ]);
    }

    private function at(string $s): CarbonImmutable
    {
        return CarbonImmutable::parse($s, 'UTC');
    }

    public function test_preparing_ack_is_quiet_until_next_cascade_point(): void
    {
        $e = $this->event('2026-09-30', '2026-08-31'); // точки: 30,15,7,3,1 → 31.08, 15.09, 23.09, 27.09, 29.09
        $due = $this->due->resolve($e, $this->user);

        $q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-02 10:00'));
        $this->assertSame('2026-09-15 00:00', $q->format('Y-m-d H:i'));

        $q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-15 10:00'));
        $this->assertSame('2026-09-23 00:00', $q->format('Y-m-d H:i'), 'на самой точке — до следующей');

        $q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-29 10:00'));
        $this->assertSame('2026-09-30 00:00', $q->format('Y-m-d H:i'), 'точек больше нет — до старта дня срока');
    }

    public function test_today_ack_skips_the_next_hour(): void
    {
        $e = $this->event('2026-09-30', '2026-09-30');
        $due = $this->due->resolve($e, $this->user);

        $q = $this->rule->afterAck($e, $this->user, EventState::Today, $due, $this->at('2026-09-30 11:17'));
        $this->assertSame('2026-09-30 13:00', $q->format('Y-m-d H:i'));
    }

    public function test_overdue_ack_skips_tomorrow(): void
    {
        $e = $this->event('2026-09-30', '2026-09-30');
        $due = $this->due->resolve($e, $this->user);

        $q = $this->rule->afterAck($e, $this->user, EventState::Overdue, $due, $this->at('2026-10-05 15:00'));
        $this->assertSame('2026-10-07 00:00', $q->format('Y-m-d H:i'));
    }

    public function test_scheduled_ack_changes_nothing(): void
    {
        $e = $this->event('2026-09-30', '2026-09-30');
        $due = $this->due->resolve($e, $this->user);
        $now = $this->at('2026-09-01 15:00');

        $this->assertTrue($this->rule->afterAck($e, $this->user, EventState::Scheduled, $due, $now)->eq($now));
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter "StateResolverTest|WindowResolverTest|QuietRuleTest"

Expected: FAIL, classes not found.

  • Step 3: Implement

core/app/Scheduling/StateResolver.php:

<?php

namespace App\Scheduling;

use App\Enums\AfterDue;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use Carbon\CarbonImmutable;

/**
 * Состояние, которое следует из дат события и «сейчас». Используется и кроном
 * (переходы), и upsert-ом (пересчёт). Терминальные Done/Withdrawn ставятся
 * только действиями, сюда не входят.
 */
final class StateResolver
{
    public function __construct(private readonly DueResolver $due) {}

    public function resolve(Event $event, User $user, CarbonImmutable $now): EventState
    {
        $due = $this->due->resolve($event, $user);

        if ($now->gte($due->moment)) {
            return $event->after_due === AfterDue::Keep ? EventState::Overdue : EventState::Expired;
        }

        if ($now->gte($this->due->todayStart($due, $user))) {
            return EventState::Today;
        }

        $fireStart = $this->due->fireStart($event, $user);
        if ($fireStart->lt($due->day) && $now->gte($fireStart)) {
            return EventState::Preparing;
        }

        return EventState::Scheduled;
    }
}

core/app/Scheduling/Window.php:

<?php

namespace App\Scheduling;

use Carbon\CarbonImmutable;

/** Окно доставки: внутри него ядро ищет момент, когда пользователь доступен. */
final class Window
{
    public function __construct(
        public readonly CarbonImmutable $start,
        public readonly CarbonImmutable $end,
        /** Разрешена ли доставка «во все каналы наугад», если никто не present. */
        public readonly bool $fallbackAllowed,
        /** С какого момента fallback срабатывает (конец окна минус запас). */
        public readonly CarbonImmutable $fallbackAt,
    ) {}
}

core/app/Scheduling/WindowResolver.php:

<?php

namespace App\Scheduling;

use App\Enums\EventState;
use App\Models\User;
use Carbon\CarbonImmutable;

final class WindowResolver
{
    /** @param list<int> $fallbackHours */
    public function __construct(
        private readonly int $dayMarginMinutes,
        private readonly int $hourMarginMinutes,
        private readonly array $fallbackHours,
    ) {}

    /** Окно, открытое в момент $now, или null (тихие часы / срок прошёл / состояние не напоминает). */
    public function current(EventState $state, User $user, Due $due, CarbonImmutable $now): ?Window
    {
        $activeStart = $user->activeStart($now);
        $activeEnd = $user->activeEnd($now);
        if ($now->lt($activeStart) || $now->gte($activeEnd)) {
            return null;
        }

        return match ($state) {
            EventState::Preparing, EventState::Overdue => new Window(
                $activeStart, $activeEnd, true, $activeEnd->subMinutes($this->dayMarginMinutes),
            ),
            EventState::Today => $this->hourly($due, $now, $activeEnd),
            default => null,
        };
    }

    private function hourly(Due $due, CarbonImmutable $now, CarbonImmutable $activeEnd): ?Window
    {
        $start = $now->startOfHour();
        $end = $start->addHour()->min($activeEnd);

        $truncated = false;
        if ($due->moment->gt($start) && $due->moment->lt($end)) {
            $end = $due->moment;
            $truncated = true;
        }

        if ($now->gte($end)) {
            return null;
        }

        $fallbackAllowed = $truncated || in_array((int) $start->format('G'), $this->fallbackHours, true);

        return new Window($start, $end, $fallbackAllowed, $end->subMinutes($this->hourMarginMinutes));
    }
}

core/app/Scheduling/QuietRule.php:

<?php

namespace App\Scheduling;

use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use Carbon\CarbonImmutable;

/** «Помню»: до какого момента молчать, по текущему состоянию. */
final class QuietRule
{
    public function __construct(private readonly DueResolver $due) {}

    public function afterAck(Event $event, User $user, EventState $state, Due $due, CarbonImmutable $now): CarbonImmutable
    {
        return match ($state) {
            EventState::Preparing => $this->nextCascadePoint($event, $user, $due, $now),
            EventState::Today => $now->startOfHour()->addHours(2),
            EventState::Overdue => $now->startOfDay()->addDays(2),
            default => $now,
        };
    }

    private function nextCascadePoint(Event $event, User $user, Due $due, CarbonImmutable $now): CarbonImmutable
    {
        $today = $now->startOfDay();
        foreach (Cascade::points($this->due->fireStart($event, $user), $due->day) as $point) {
            if ($point->gt($today)) {
                return $point;
            }
        }

        return $this->due->todayStart($due, $user);
    }
}

Bind the resolver from config in core/app/Providers/AppServiceProvider.php register():

$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
cd core && php artisan test --filter "StateResolverTest|WindowResolverTest|QuietRuleTest"

Expected: 14 tests PASS.

  • Step 5: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: state, window and quiet rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 5: DueAtParser and EventUpserter

Files:

  • Create: core/app/Ingest/ParsedDue.php, core/app/Ingest/DueAtParser.php, core/app/Ingest/EventUpserter.php
  • Test: core/tests/Unit/Ingest/DueAtParserTest.php, core/tests/Feature/EventUpserterTest.php

Interfaces:

  • Consumes: StateResolver, models, config hado.default_tz.

  • Produces:

    • final class ParsedDue { DueMode $mode; ?string $date; ?string $time; ?CarbonImmutable $instant; } with static local(string $date, string $time), static fixed(CarbonImmutable), dayFor(string $tz): string (Y-m-d), equals(Event): bool.
    • DueAtParser::parse(string): ParsedDue throws InvalidArgumentException on bad input.
    • EventUpserter::upsert(Source $source, array $items): list<array{source_ref,topic,id,state}>$items are already-validated request rows.
    • EventUpserter::ensureUser(string $login): User — creates user + web channel.
    • EventUpserter::withdraw(Source, string $sourceRef, string $topic): bool
  • Step 1: Failing tests

core/tests/Unit/Ingest/DueAtParserTest.php:

<?php

namespace Tests\Unit\Ingest;

use App\Enums\DueMode;
use App\Ingest\DueAtParser;
use InvalidArgumentException;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory

class DueAtParserTest extends TestCase
{
    public function test_date_only_is_whole_day_local(): void
    {
        $p = DueAtParser::parse('2026-08-14');
        $this->assertSame(DueMode::Local, $p->mode);
        $this->assertSame('2026-08-14', $p->date);
        $this->assertSame('24:00:00', $p->time);
        $this->assertNull($p->instant);
        $this->assertSame('2026-08-14', $p->dayFor('Asia/Shanghai'));
    }

    public function test_datetime_without_offset_is_local(): void
    {
        $p = DueAtParser::parse('2026-09-10T14:00');
        $this->assertSame(DueMode::Local, $p->mode);
        $this->assertSame('2026-09-10', $p->date);
        $this->assertSame('14:00:00', $p->time);

        $this->assertSame('14:05:30', DueAtParser::parse('2026-09-10T14:05:30')->time);
    }

    public function test_datetime_with_offset_is_fixed(): void
    {
        $p = DueAtParser::parse('2026-09-10T08:15+02:00');
        $this->assertSame(DueMode::Fixed, $p->mode);
        $this->assertNull($p->date);
        $this->assertSame('2026-09-10T06:15:00+00:00', $p->instant->utc()->toIso8601String());
        $this->assertSame('2026-09-10', $p->dayFor('America/Montevideo'));
        $this->assertSame('2026-09-10', $p->dayFor('Asia/Shanghai'));

        $this->assertSame(DueMode::Fixed, DueAtParser::parse('2026-09-10T06:15:00Z')->mode);
    }

    public function test_garbage_is_rejected(): void
    {
        foreach (['', 'tomorrow', '2026-13-01', '14:00', '2026-09-10 14:00', '2026-09-10T25:00'] as $bad) {
            try {
                DueAtParser::parse($bad);
                $this->fail("accepted: $bad");
            } catch (InvalidArgumentException) {
                $this->addToAssertionCount(1);
            }
        }
    }
}

core/tests/Feature/EventUpserterTest.php:

<?php

namespace Tests\Feature;

use App\Enums\ChannelType;
use App\Enums\EventState;
use App\Ingest\EventUpserter;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class EventUpserterTest extends TestCase
{
    use RefreshDatabase;

    private Source $source;
    private EventUpserter $upserter;

    protected function setUp(): void
    {
        parent::setUp();
        config(['hado.default_tz' => 'America/Montevideo']);
        CarbonImmutable::setTestNow('2026-09-01T12:00:00-03:00');
        $this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $this->upserter = app(EventUpserter::class);
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    private function item(array $over = []): array
    {
        return array_replace([
            'user' => 'nikita', 'source_ref' => 'document:918', 'topic' => 'expiry',
            'due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep',
            'payload' => ['title' => 'Седула протухает 30 сен'],
        ], $over);
    }

    public function test_creates_event_user_and_web_channel_with_computed_state(): void
    {
        $result = $this->upserter->upsert($this->source, [$this->item()]);

        $this->assertSame([['source_ref' => 'document:918', 'topic' => 'expiry', 'id' => 1, 'state' => 'preparing']], $result);
        $user = User::where('login', 'nikita')->firstOrFail();
        $this->assertSame('America/Montevideo', $user->tz);
        $this->assertSame(ChannelType::Web, $user->channels()->sole()->type);
        $event = Event::sole();
        $this->assertSame(EventState::Preparing, $event->state);
        $this->assertTrue($event->quiet_until->lt(CarbonImmutable::now()));
    }

    public function test_same_due_updates_fields_and_keeps_terminal_state(): void
    {
        $this->upserter->upsert($this->source, [$this->item()]);
        Event::sole()->update(['state' => EventState::Done, 'done_at' => now()]);

        $this->upserter->upsert($this->source, [$this->item(['payload' => ['title' => 'Новый заголовок'], 'fire_at' => '2026-09-15'])]);

        $event = Event::sole();
        $this->assertSame(EventState::Done, $event->state);
        $this->assertSame('Новый заголовок', $event->payload['title']);
        $this->assertSame('2026-09-15', $event->fire_on->format('Y-m-d'));
    }

    public function test_same_due_recomputes_non_terminal_state(): void
    {
        $this->upserter->upsert($this->source, [$this->item()]);
        $this->assertSame(EventState::Preparing, Event::sole()->state);

        $this->upserter->upsert($this->source, [$this->item(['fire_at' => '2026-09-20'])]);
        $this->assertSame(EventState::Scheduled, Event::sole()->state, 'старт отодвинули в будущее');
    }

    public function test_new_due_resets_even_from_terminal(): void
    {
        $this->upserter->upsert($this->source, [$this->item()]);
        Event::sole()->update(['state' => EventState::Done, 'done_at' => now(), 'quiet_until' => CarbonImmutable::now()->addDays(3)]);

        $this->upserter->upsert($this->source, [$this->item(['due_at' => '2026-10-15'])]);

        $event = Event::sole();
        $this->assertSame(EventState::Preparing, $event->state);
        $this->assertNull($event->done_at);
        $this->assertTrue($event->quiet_until->lt(CarbonImmutable::now()));
        $this->assertSame('2026-10-15', $event->due_date->format('Y-m-d'));
    }

    public function test_reschedule_on_the_day_resets_today_event(): void
    {
        // Задокументированное поведение: перенос в день срока сбрасывает состояние.
        // Поэтому повторяющиеся события кладут период в topic, а не меняют due_at.
        CarbonImmutable::setTestNow('2026-09-30T10:00:00-03:00');
        $this->upserter->upsert($this->source, [$this->item()]);
        $this->assertSame(EventState::Today, Event::sole()->state);

        $this->upserter->upsert($this->source, [$this->item(['due_at' => '2027-09-30', 'fire_at' => '2027-08-31'])]);
        $this->assertSame(EventState::Scheduled, Event::sole()->state);
    }

    public function test_occurrences_are_independent_rows(): void
    {
        CarbonImmutable::setTestNow('2026-10-05T10:00:00-03:00');
        $this->upserter->upsert($this->source, [
            $this->item(['source_ref' => 'rent', 'topic' => 'payment:2026-09', 'due_at' => '2026-09-10', 'fire_at' => '2026-09-10']),
            $this->item(['source_ref' => 'rent', 'topic' => 'payment:2026-10', 'due_at' => '2026-10-10', 'fire_at' => '2026-10-10']),
        ]);

        $states = Event::orderBy('topic')->pluck('state')->map->value->all();
        $this->assertSame(['overdue', 'scheduled'], $states);
    }

    public function test_fixed_due_is_stored_as_instant(): void
    {
        $this->upserter->upsert($this->source, [$this->item(['due_at' => '2026-09-10T08:15+02:00', 'fire_at' => '2026-09-03'])]);

        $event = Event::sole();
        $this->assertSame('fixed', $event->due_mode->value);
        $this->assertSame('2026-09-10T06:15:00+00:00', $event->due_instant->utc()->toIso8601String());
        $this->assertNull($event->due_date);
    }

    public function test_withdraw_marks_withdrawn_and_reports_missing(): void
    {
        $this->upserter->upsert($this->source, [$this->item()]);

        $this->assertTrue($this->upserter->withdraw($this->source, 'document:918', 'expiry'));
        $this->assertSame(EventState::Withdrawn, Event::sole()->state);
        $this->assertFalse($this->upserter->withdraw($this->source, 'document:918', 'other'));
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter "DueAtParserTest|EventUpserterTest"

Expected: FAIL, classes not found.

  • Step 3: Implement

core/app/Ingest/ParsedDue.php:

<?php

namespace App\Ingest;

use App\Enums\DueMode;
use App\Models\Event;
use Carbon\CarbonImmutable;

/** Разобранный due_at: tagged union по mode. */
final class ParsedDue
{
    private function __construct(
        public readonly DueMode $mode,
        public readonly ?string $date,
        public readonly ?string $time,
        public readonly ?CarbonImmutable $instant,
    ) {}

    public static function local(string $date, string $time): self
    {
        return new self(DueMode::Local, $date, $time, null);
    }

    public static function fixed(CarbonImmutable $instant): self
    {
        return new self(DueMode::Fixed, null, null, $instant->utc());
    }

    /** Календарный день срока в данном поясе, Y-m-d. */
    public function dayFor(string $tz): string
    {
        return match ($this->mode) {
            DueMode::Local => $this->date,
            DueMode::Fixed => $this->instant->setTimezone($tz)->format('Y-m-d'),
        };
    }

    public function equals(Event $event): bool
    {
        if ($event->due_mode !== $this->mode) {
            return false;
        }

        return match ($this->mode) {
            DueMode::Local => $event->due_date->format('Y-m-d') === $this->date && $event->due_time === $this->time,
            DueMode::Fixed => $event->due_instant->eq($this->instant),
        };
    }

    /** @return array<string, mixed> колонки events */
    public function columns(): array
    {
        return [
            'due_mode' => $this->mode,
            'due_date' => $this->date,
            'due_time' => $this->time,
            'due_instant' => $this->instant,
        ];
    }
}

core/app/Ingest/DueAtParser.php:

<?php

namespace App\Ingest;

use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use InvalidArgumentException;

final class DueAtParser
{
    private const DATE = '/^(\d{4})-(\d{2})-(\d{2})$/';
    private const LOCAL_DATETIME = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/';
    private const FIXED_DATETIME = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(Z|[+-]\d{2}:\d{2})$/';

    public static function parse(string $raw): ParsedDue
    {
        if (preg_match(self::DATE, $raw, $m)) {
            self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);

            return ParsedDue::local($raw, DueResolver::WHOLE_DAY);
        }

        if (preg_match(self::LOCAL_DATETIME, $raw, $m)) {
            self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
            self::assertTime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), $raw);

            return ParsedDue::local(
                sprintf('%s-%s-%s', $m[1], $m[2], $m[3]),
                sprintf('%s:%s:%02d', $m[4], $m[5], (int) ($m[6] ?? 0)),
            );
        }

        if (preg_match(self::FIXED_DATETIME, $raw)) {
            try {
                return ParsedDue::fixed(CarbonImmutable::parse($raw));
            } catch (\Throwable $e) {
                throw new InvalidArgumentException("due_at: невалидная дата-время «{$raw}»", 0, $e);
            }
        }

        throw new InvalidArgumentException("due_at: ожидается YYYY-MM-DD, YYYY-MM-DDTHH:MM или дата-время со смещением, получено «{$raw}»");
    }

    private static function assertDate(int $y, int $m, int $d, string $raw): void
    {
        if (! checkdate($m, $d, $y)) {
            throw new InvalidArgumentException("due_at: несуществующая дата «{$raw}»");
        }
    }

    private static function assertTime(int $h, int $i, int $s, string $raw): void
    {
        if ($h > 23 || $i > 59 || $s > 59) {
            throw new InvalidArgumentException("due_at: невалидное время «{$raw}»");
        }
    }
}

core/app/Ingest/EventUpserter.php:

<?php

namespace App\Ingest;

use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use App\Scheduling\StateResolver;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;

final class EventUpserter
{
    public function __construct(private readonly StateResolver $states) {}

    /**
     * @param list<array{user:string,source_ref:string,topic?:string,due_at:string,fire_at:string,after_due:string,payload:array}> $items
     * @return list<array{source_ref:string,topic:string,id:int,state:string}>
     */
    public function upsert(Source $source, array $items): array
    {
        return DB::transaction(function () use ($source, $items) {
            $out = [];
            foreach ($items as $item) {
                $event = $this->upsertOne($source, $item);
                $out[] = [
                    'source_ref' => $event->source_ref,
                    'topic' => $event->topic,
                    'id' => $event->id,
                    'state' => $event->state->value,
                ];
            }

            return $out;
        });
    }

    public function withdraw(Source $source, string $sourceRef, string $topic): bool
    {
        $event = $this->find($source, $sourceRef, $topic);
        if ($event === null) {
            return false;
        }
        $event->update(['state' => EventState::Withdrawn]);

        return true;
    }

    public function find(Source $source, string $sourceRef, string $topic): ?Event
    {
        return Event::where('source_id', $source->id)
            ->where('source_ref', $sourceRef)
            ->where('topic', $topic)
            ->first();
    }

    /** Пользователь по логину; создаётся с поясом по умолчанию и web-каналом. */
    public function ensureUser(string $login): User
    {
        $user = User::firstOrCreate(
            ['login' => $login],
            ['tz' => config('hado.default_tz'), 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00'],
        );
        $user->channels()->firstOrCreate(['type' => ChannelType::Web->value], ['config' => [], 'enabled' => true]);

        return $user;
    }

    private function upsertOne(Source $source, array $item): Event
    {
        $user = $this->ensureUser($item['user']);
        $due = DueAtParser::parse($item['due_at']);
        $topic = $item['topic'] ?? '';
        $now = $user->now();

        $attributes = [
            'user_id' => $user->id,
            'fire_on' => $item['fire_at'],
            'after_due' => AfterDue::from($item['after_due']),
            'payload' => $item['payload'],
        ] + $due->columns();

        $event = $this->find($source, $item['source_ref'], $topic);

        if ($event === null) {
            $event = new Event([
                'source_id' => $source->id,
                'source_ref' => $item['source_ref'],
                'topic' => $topic,
                'state' => EventState::Scheduled,
                'quiet_until' => $now->subMinute(),
            ] + $attributes);
            $event->state = $this->states->resolve($event, $user, $now);
            $event->save();

            return $event;
        }

        $dueChanged = ! $due->equals($event);
        $event->fill($attributes);

        if ($dueChanged) {
            // Перенос: новая дата — новый заход, даже из терминального состояния.
            $event->done_at = null;
            $event->quiet_until = $now->subMinute();
            $event->state = $this->states->resolve($event, $user, $now);
        } elseif (! $event->isTerminal()) {
            $event->state = $this->states->resolve($event, $user, $now);
        }

        $event->save();

        return $event;
    }
}
  • Step 4: Run tests
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
cd /d/ai-projects/hado && git add -A && git commit -m "feat: due_at parser and event upserter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 6: Source tokens and the client API

Files:

  • Create: core/app/Http/Middleware/AuthenticateSource.php, core/app/Console/Commands/SourceCreate.php, core/app/Http/Requests/UpsertEventsRequest.php, core/app/Http/Controllers/Api/EventsController.php, core/routes/api.php
  • Modify: core/bootstrap/app.php (register api routes, middleware alias, JSON exceptions)
  • Test: core/tests/Feature/ClientApiTest.php, core/tests/Feature/SourceCreateCommandTest.php

Interfaces:

  • Consumes: EventUpserter, DueAtParser.

  • Produces: routes PUT /api/events, DELETE /api/events?source_ref&topic, GET /api/events?source_ref&topic; request attribute source (Source model) set by middleware alias auth.source; artisan hado:source:create {name} printing Token: hado_<64 hex> once. Token hash = hash('sha256', $token).

  • Step 1: Failing tests

core/tests/Feature/SourceCreateCommandTest.php:

<?php

namespace Tests\Feature;

use App\Models\Source;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SourceCreateCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_source_and_prints_token_once(): void
    {
        $this->artisan('hado:source:create', ['name' => 'docs'])
            ->expectsOutputToContain('Token: hado_')
            ->assertSuccessful();

        $source = Source::where('name', 'docs')->sole();
        $this->assertSame(64, strlen($source->token_hash));
    }

    public function test_refuses_duplicate_name(): void
    {
        Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);

        $this->artisan('hado:source:create', ['name' => 'docs'])->assertFailed();
    }
}

core/tests/Feature/ClientApiTest.php:

<?php

namespace Tests\Feature;

use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ClientApiTest extends TestCase
{
    use RefreshDatabase;

    private const TOKEN = 'hado_testtoken';

    protected function setUp(): void
    {
        parent::setUp();
        CarbonImmutable::setTestNow('2026-09-01T12:00:00Z');
        Source::create(['name' => 'docs', 'token_hash' => hash('sha256', self::TOKEN)]);
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    private function item(array $over = []): array
    {
        return array_replace([
            'user' => 'nikita', 'source_ref' => 'person:42', 'topic' => 'birthday:2026',
            'due_at' => '2026-08-14', 'fire_at' => '2026-08-14', 'after_due' => 'expire',
            'payload' => ['title' => 'Сегодня ДР — Вася', 'deep_link' => '/people/42'],
        ], $over);
    }

    private function auth(): array
    {
        return ['Authorization' => 'Bearer '.self::TOKEN];
    }

    public function test_rejects_missing_or_wrong_token(): void
    {
        $this->putJson('/api/events', ['events' => [$this->item()]])->assertStatus(401);
        $this->putJson('/api/events', ['events' => [$this->item()]], ['Authorization' => 'Bearer nope'])->assertStatus(401);
    }

    public function test_upserts_batch_and_returns_ids_and_states(): void
    {
        $this->putJson('/api/events', ['events' => [$this->item()]], $this->auth())
            ->assertOk()
            ->assertJsonPath('events.0.source_ref', 'person:42')
            ->assertJsonPath('events.0.topic', 'birthday:2026')
            ->assertJsonPath('events.0.state', 'expired');

        $this->assertSame(1, Event::count());
    }

    public function test_validation_rejects_whole_batch_with_index(): void
    {
        $bad = $this->item(['due_at' => 'tomorrow']);
        $this->putJson('/api/events', ['events' => [$this->item(), $bad]], $this->auth())
            ->assertStatus(422)
            ->assertJsonValidationErrors(['events.1.due_at']);

        $this->assertSame(0, Event::count(), 'ничего не применилось');
    }

    public function test_validation_rules(): void
    {
        $this->putJson('/api/events', ['events' => [$this->item(['fire_at' => '2026-08-15'])]], $this->auth())
            ->assertStatus(422)->assertJsonValidationErrors(['events.0.fire_at']);
        $this->putJson('/api/events', ['events' => [$this->item(['after_due' => 'maybe'])]], $this->auth())
            ->assertStatus(422)->assertJsonValidationErrors(['events.0.after_due']);
        $this->putJson('/api/events', ['events' => [$this->item(['payload' => ['subtitle' => 'x']])]], $this->auth())
            ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.title']);
        $this->putJson('/api/events', ['events' => [$this->item(['user' => ''])]], $this->auth())
            ->assertStatus(422)->assertJsonValidationErrors(['events.0.user']);
        $this->putJson('/api/events', ['events' => array_fill(0, 501, $this->item())], $this->auth())
            ->assertStatus(422)->assertJsonValidationErrors(['events']);
    }

    public function test_topic_defaults_to_empty_string(): void
    {
        $item = $this->item();
        unset($item['topic']);
        $this->putJson('/api/events', ['events' => [$item]], $this->auth())->assertOk()->assertJsonPath('events.0.topic', '');
    }

    public function test_get_and_delete_by_identity(): void
    {
        $this->putJson('/api/events', ['events' => [$this->item(['due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep'])]], $this->auth());

        $this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', $this->auth())
            ->assertOk()
            ->assertJsonPath('state', 'preparing')
            ->assertJsonPath('due_at', '2026-09-30')
            ->assertJsonPath('topic', 'birthday:2026');

        $this->deleteJson('/api/events?source_ref=person:42&topic=birthday:2026', [], $this->auth())->assertNoContent();
        $this->assertSame(EventState::Withdrawn, Event::sole()->state);

        $this->getJson('/api/events?source_ref=person:42&topic=nope', $this->auth())->assertNotFound();
        $this->deleteJson('/api/events?source_ref=person:42&topic=nope', [], $this->auth())->assertNotFound();
    }

    public function test_source_is_isolated_by_token(): void
    {
        Source::create(['name' => 'other', 'token_hash' => hash('sha256', 'other-token')]);
        $this->putJson('/api/events', ['events' => [$this->item()]], $this->auth());

        $this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', ['Authorization' => 'Bearer other-token'])->assertNotFound();
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter "ClientApiTest|SourceCreateCommandTest"

Expected: FAIL (404s / command not found).

  • Step 3: Implement

core/app/Http/Middleware/AuthenticateSource.php:

<?php

namespace App\Http\Middleware;

use App\Models\Source;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateSource
{
    public function handle(Request $request, Closure $next): Response
    {
        $token = $request->bearerToken();
        if ($token === null || $token === '') {
            return response()->json(['message' => 'Нужен Bearer-токен источника'], 401);
        }

        $source = Source::where('token_hash', hash('sha256', $token))->first();
        if ($source === null) {
            return response()->json(['message' => 'Неизвестный токен источника'], 401);
        }

        $request->attributes->set('source', $source);

        return $next($request);
    }
}

core/app/Console/Commands/SourceCreate.php:

<?php

namespace App\Console\Commands;

use App\Models\Source;
use Illuminate\Console\Command;

class SourceCreate extends Command
{
    protected $signature = 'hado:source:create {name : Имя источника, напр. docs}';

    protected $description = 'Регистрирует сервис-клиент и печатает его токен (один раз)';

    public function handle(): int
    {
        $name = (string) $this->argument('name');
        if (Source::where('name', $name)->exists()) {
            $this->error("Источник «{$name}» уже существует");

            return self::FAILURE;
        }

        $token = 'hado_'.bin2hex(random_bytes(32));
        Source::create(['name' => $name, 'token_hash' => hash('sha256', $token)]);

        $this->info("Источник «{$name}» создан. Токен показывается один раз, в базе только хэш.");
        $this->line("Token: {$token}");

        return self::SUCCESS;
    }
}

core/app/Http/Requests/UpsertEventsRequest.php:

<?php

namespace App\Http\Requests;

use App\Ingest\DueAtParser;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use InvalidArgumentException;

class UpsertEventsRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'events' => ['required', 'array', 'min:1', 'max:500'],
            'events.*.user' => ['required', 'string', 'max:255'],
            'events.*.source_ref' => ['required', 'string', 'max:255'],
            'events.*.topic' => ['sometimes', 'nullable', 'string', 'max:255'],
            'events.*.due_at' => ['required', 'string'],
            'events.*.fire_at' => ['required', 'date_format:Y-m-d'],
            'events.*.after_due' => ['required', 'in:keep,expire'],
            'events.*.payload' => ['required', 'array'],
            'events.*.payload.title' => ['required', 'string'],
            'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'],
            'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string'],
            'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'],
        ];
    }

    public function after(): array
    {
        return [function (Validator $v) {
            foreach ((array) $this->input('events', []) as $i => $item) {
                if (! is_array($item) || ! isset($item['due_at'], $item['fire_at']) || ! is_string($item['due_at'])) {
                    continue;
                }
                try {
                    $due = DueAtParser::parse($item['due_at']);
                } catch (InvalidArgumentException $e) {
                    $v->errors()->add("events.$i.due_at", $e->getMessage());
                    continue;
                }
                // Проверка fire_at <= день срока — по поясу по умолчанию; для fixed это
                // приближение, точный день пользователя известен только при upsert.
                if ($v->errors()->missing("events.$i.fire_at") && $item['fire_at'] > $due->dayFor(config('hado.default_tz'))) {
                    $v->errors()->add("events.$i.fire_at", 'fire_at не может быть позже дня срока');
                }
            }
        }];
    }

    /** @return list<array<string,mixed>> */
    public function items(): array
    {
        return array_map(function (array $item) {
            $item['topic'] = (string) ($item['topic'] ?? '');

            return $item;
        }, $this->validated()['events']);
    }
}

core/app/Http/Controllers/Api/EventsController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\UpsertEventsRequest;
use App\Ingest\EventUpserter;
use App\Models\Event;
use App\Models\Source;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class EventsController extends Controller
{
    public function __construct(private readonly EventUpserter $upserter) {}

    public function upsert(UpsertEventsRequest $request): JsonResponse
    {
        /** @var Source $source */
        $source = $request->attributes->get('source');

        return response()->json(['events' => $this->upserter->upsert($source, $request->items())]);
    }

    public function show(Request $request): JsonResponse
    {
        $event = $this->lookup($request);

        return response()->json(self::present($event));
    }

    public function destroy(Request $request): Response
    {
        $event = $this->lookup($request);
        $event->update(['state' => \App\Enums\EventState::Withdrawn]);

        return response()->noContent();
    }

    private function lookup(Request $request): Event
    {
        $request->validate(['source_ref' => ['required', 'string'], 'topic' => ['sometimes', 'nullable', 'string']]);
        /** @var Source $source */
        $source = $request->attributes->get('source');
        $event = $this->upserter->find($source, $request->query('source_ref'), (string) $request->query('topic', ''));
        abort_if($event === null, 404, 'Событие не найдено');

        return $event;
    }

    /** @return array<string,mixed> */
    public static function present(Event $event): array
    {
        return [
            'id' => $event->id,
            'source_ref' => $event->source_ref,
            'topic' => $event->topic,
            'state' => $event->state->value,
            'due_at' => self::dueAt($event),
            'fire_at' => $event->fire_on->format('Y-m-d'),
            'after_due' => $event->after_due->value,
            'payload' => $event->payload,
            'done_at' => $event->done_at?->toIso8601String(),
        ];
    }

    private static function dueAt(Event $event): string
    {
        if ($event->due_mode === \App\Enums\DueMode::Fixed) {
            return $event->due_instant->toIso8601String();
        }
        $date = $event->due_date->format('Y-m-d');

        return $event->due_time === \App\Scheduling\DueResolver::WHOLE_DAY ? $date : $date.'T'.substr($event->due_time, 0, 5);
    }
}

core/routes/api.php:

<?php

use App\Http\Controllers\Api\EventsController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth.source')->group(function () {
    Route::put('/events', [EventsController::class, 'upsert']);
    Route::get('/events', [EventsController::class, 'show']);
    Route::delete('/events', [EventsController::class, 'destroy']);
});

core/bootstrap/app.php — register api routes and the alias:

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->trustProxies(at: '*');
        $middleware->alias([
            'auth.source' => \App\Http\Middleware\AuthenticateSource::class,
            'remote.user' => \App\Http\Middleware\RemoteUser::class,
        ]);
        $middleware->validateCsrfTokens(except: ['a/*', 'hooks/*']);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->expectsJson() || $request->is('api/*') || $request->is('a/*') || $request->is('hooks/*'),
        );
    })->create();

RemoteUser is created in Task 7; until then create an empty pass-through class so the alias resolves:

core/app/Http/Middleware/RemoteUser.php (temporary, replaced in Task 7):

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RemoteUser
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }
}
  • Step 4: Run tests
cd core && php artisan test --filter "ClientApiTest|SourceCreateCommandTest"

Expected: 9 tests PASS.

  • Step 5: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: source tokens and client events API

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 7: RemoteUser middleware, EventActions and the /me API

Files:

  • Replace: core/app/Http/Middleware/RemoteUser.php
  • Create: core/app/Delivery/EventActions.php, core/app/Http/Controllers/Me/ProfileController.php, core/app/Http/Controllers/Me/EventsController.php
  • Modify: core/routes/web.php
  • Test: core/tests/Feature/MeApiTest.php

Interfaces:

  • Consumes: QuietRule, DueResolver, EventUpserter::ensureUser, ChannelRegistry (Task 8; for onDone — until Task 8 exists, EventActions::done only changes state; Task 8 adds the hook).

  • Produces:

    • middleware alias remote.user: reads X-Remote-User, 401 if missing, ensureUser, sets request attribute user.
    • EventActions::ack(Event): void, EventActions::done(Event): void (idempotent on terminal events).
    • Routes: GET /me, PATCH /me, GET /me/events?state=, POST /me/events/{event}/ack, POST /me/events/{event}/done, POST /me/heartbeat.
    • Heartbeat cache key: heartbeat:{user_id}, TTL hado.web_heartbeat_ttl.
  • Step 1: Failing test

core/tests/Feature/MeApiTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class MeApiTest extends TestCase
{
    use RefreshDatabase;

    private Source $source;

    protected function setUp(): void
    {
        parent::setUp();
        config(['hado.default_tz' => 'UTC']);
        CarbonImmutable::setTestNow('2026-09-30T11:17:00Z');
        $this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    private function as(string $login = 'nikita'): array
    {
        return ['X-Remote-User' => $login];
    }

    private function event(User $user, EventState $state, string $topic = 't', string $due = '2026-09-30', string $fireOn = '2026-09-30'): Event
    {
        return Event::create([
            'source_id' => $this->source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
            'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => '24:00:00', 'fire_on' => $fireOn,
            'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => $state,
            'quiet_until' => CarbonImmutable::now()->subDay(),
        ]);
    }

    public function test_requires_remote_user_header(): void
    {
        $this->getJson('/me')->assertStatus(401);
    }

    public function test_first_visit_creates_user_with_web_channel(): void
    {
        $this->getJson('/me', $this->as())->assertOk()
            ->assertJsonPath('login', 'nikita')
            ->assertJsonPath('tz', 'UTC')
            ->assertJsonPath('quiet_start', '22:00')
            ->assertJsonPath('quiet_end', '09:00')
            ->assertJsonPath('badge', 0);

        $this->assertSame(ChannelType::Web, User::sole()->channels()->sole()->type);
    }

    public function test_badge_counts_today_and_overdue_only(): void
    {
        $this->getJson('/me', $this->as());
        $user = User::sole();
        $this->event($user, EventState::Today, 'a');
        $this->event($user, EventState::Overdue, 'b');
        $this->event($user, EventState::Preparing, 'c');
        $this->event($user, EventState::Done, 'd');

        $this->getJson('/me', $this->as())->assertJsonPath('badge', 2);
    }

    public function test_patch_profile_validates_timezone_and_quiet_hours(): void
    {
        $this->patchJson('/me', ['tz' => 'Asia/Shanghai', 'quiet_start' => '23:00', 'quiet_end' => '08:00'], $this->as())
            ->assertOk()->assertJsonPath('tz', 'Asia/Shanghai')->assertJsonPath('quiet_start', '23:00');

        $this->patchJson('/me', ['tz' => 'Mars/Olympus'], $this->as())->assertStatus(422);
        $this->patchJson('/me', ['quiet_start' => '08:00', 'quiet_end' => '22:00'], $this->as())
            ->assertStatus(422)->assertJsonValidationErrors(['quiet_end']);
    }

    public function test_lists_own_non_terminal_events(): void
    {
        $this->getJson('/me', $this->as());
        $this->getJson('/me', $this->as('other'));
        $me = User::where('login', 'nikita')->sole();
        $other = User::where('login', 'other')->sole();
        $this->event($me, EventState::Today, 'a');
        $this->event($me, EventState::Done, 'b');
        $this->event($other, EventState::Today, 'c');

        $this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a');
        $this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b');
    }

    public function test_ack_sets_quiet_until_and_done_closes(): void
    {
        $this->getJson('/me', $this->as());
        $user = User::sole();
        $today = $this->event($user, EventState::Today, 'a');
        $done = $this->event($user, EventState::Preparing, 'b', '2026-10-30', '2026-09-30');

        $this->postJson("/me/events/{$today->id}/ack", [], $this->as())->assertOk()->assertJsonPath('state', 'today');
        $this->assertSame('2026-09-30T13:00:00+00:00', $today->fresh()->quiet_until->toIso8601String());

        $this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk()->assertJsonPath('state', 'done');
        $this->assertNotNull($done->fresh()->done_at);

        $this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk(); // идемпотентно
    }

    public function test_cannot_touch_someone_elses_event(): void
    {
        $this->getJson('/me', $this->as('other'));
        $event = $this->event(User::sole(), EventState::Today);

        $this->postJson("/me/events/{$event->id}/done", [], $this->as('nikita'))->assertNotFound();
    }

    public function test_heartbeat_marks_web_presence(): void
    {
        $this->postJson('/me/heartbeat', [], $this->as())->assertNoContent();
        $this->assertTrue(Cache::has('heartbeat:'.User::sole()->id));
    }
}
  • Step 2: Run to verify failure
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

namespace App\Http\Middleware;

use App\Ingest\EventUpserter;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Доверяет X-Remote-User от Caddy forward_auth (сеть изолирована, как в sekai).
 * На публичных маршрутах (/api, /a, /hooks) этот middleware не стоит.
 */
class RemoteUser
{
    public function __construct(private readonly EventUpserter $users) {}

    public function handle(Request $request, Closure $next): Response
    {
        $login = trim((string) $request->header('X-Remote-User', ''));
        if ($login === '') {
            return response()->json(['message' => 'Нет X-Remote-User: запрос должен идти через хаб'], 401);
        }

        $request->attributes->set('user', $this->users->ensureUser($login));

        return $next($request);
    }
}

core/app/Delivery/EventActions.php:

<?php

namespace App\Delivery;

use App\Enums\EventState;
use App\Models\Event;
use App\Scheduling\DueResolver;
use App\Scheduling\QuietRule;

final class EventActions
{
    public function __construct(
        private readonly DueResolver $due,
        private readonly QuietRule $quiet,
    ) {}

    /** «Помню»: молчать до следующей контрольной точки текущего состояния. */
    public function ack(Event $event): void
    {
        if ($event->isTerminal()) {
            return;
        }
        $user = $event->user;
        $now = $user->now();
        $event->quiet_until = $this->quiet->afterAck($event, $user, $event->state, $this->due->resolve($event, $user), $now);
        $event->save();
    }

    /** «Сделано»: закрыть событие целиком. */
    public function done(Event $event): void
    {
        if ($event->isTerminal()) {
            return;
        }
        $event->state = EventState::Done;
        $event->done_at = now();
        $event->save();
    }
}

core/app/Http/Controllers/Me/ProfileController.php:

<?php

namespace App\Http\Controllers\Me;

use App\Enums\EventState;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class ProfileController extends Controller
{
    public function show(Request $request): JsonResponse
    {
        return response()->json(self::present($request->attributes->get('user')));
    }

    public function update(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->attributes->get('user');

        $data = $request->validate([
            'tz' => ['sometimes', 'string', Rule::in(\DateTimeZone::listIdentifiers())],
            'quiet_start' => ['sometimes', 'date_format:H:i'],
            'quiet_end' => ['sometimes', 'date_format:H:i'],
        ]);

        $start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5);
        $end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5);
        if ($end >= $start) {
            throw \Illuminate\Validation\ValidationException::withMessages([
                'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start',
            ]);
        }

        $user->fill([
            'tz' => $data['tz'] ?? $user->tz,
            'quiet_start' => $start.':00',
            'quiet_end' => $end.':00',
        ])->save();

        return response()->json(self::present($user));
    }

    public function heartbeat(Request $request): Response
    {
        /** @var User $user */
        $user = $request->attributes->get('user');
        Cache::put("heartbeat:{$user->id}", true, config('hado.web_heartbeat_ttl'));

        return response()->noContent();
    }

    /** @return array<string,mixed> */
    public static function present(User $user): array
    {
        return [
            'login' => $user->login,
            'tz' => $user->tz,
            'quiet_start' => substr($user->quiet_start, 0, 5),
            'quiet_end' => substr($user->quiet_end, 0, 5),
            'badge' => $user->events()->whereIn('state', [EventState::Today->value, EventState::Overdue->value])->count(),
        ];
    }
}

core/app/Http/Controllers/Me/EventsController.php:

<?php

namespace App\Http\Controllers\Me;

use App\Delivery\EventActions;
use App\Enums\EventState;
use App\Http\Controllers\Api\EventsController as ApiPresenter;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class EventsController extends Controller
{
    public function __construct(private readonly EventActions $actions) {}

    public function index(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->attributes->get('user');
        $states = $request->query('state')
            ? [$request->query('state')]
            : EventState::nonTerminalValues();

        $events = $user->events()->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get();

        return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]);
    }

    public function ack(Request $request, int $event): JsonResponse
    {
        $model = $this->own($request, $event);
        $this->actions->ack($model);

        return response()->json(ApiPresenter::present($model->fresh()));
    }

    public function done(Request $request, int $event): JsonResponse
    {
        $model = $this->own($request, $event);
        $this->actions->done($model);

        return response()->json(ApiPresenter::present($model->fresh()));
    }

    private function own(Request $request, int $id): Event
    {
        /** @var User $user */
        $user = $request->attributes->get('user');

        return $user->events()->whereKey($id)->firstOrFail();
    }
}

core/routes/web.php (replace):

<?php

use App\Http\Controllers\Me\EventsController as MeEvents;
use App\Http\Controllers\Me\ProfileController;
use Illuminate\Support\Facades\Route;

Route::middleware('remote.user')->group(function () {
    Route::get('/me', [ProfileController::class, 'show']);
    Route::patch('/me', [ProfileController::class, 'update']);
    Route::post('/me/heartbeat', [ProfileController::class, 'heartbeat']);
    Route::get('/me/events', [MeEvents::class, 'index']);
    Route::post('/me/events/{event}/ack', [MeEvents::class, 'ack']);
    Route::post('/me/events/{event}/done', [MeEvents::class, 'done']);
});
  • Step 4: Run tests
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
cd /d/ai-projects/hado && git add -A && git commit -m "feat: remote user, ack/done actions and /me API

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 8: Channel drivers (web, webhook, telegram) and registry

Files:

  • Create: core/app/Channels/ChannelDriver.php, DeliveryOutcome.php, ChannelRegistry.php, WebChannel.php, WebhookChannel.php, TelegramChannel.php
  • Modify: core/app/Delivery/EventActions.php (call onDone for each delivery), core/app/Providers/AppServiceProvider.php (bind registry)
  • Test: core/tests/Unit/Channels/WebhookChannelTest.php, core/tests/Unit/Channels/TelegramChannelTest.php, core/tests/Unit/Channels/WebChannelTest.php

Interfaces:

  • Produces:

    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

namespace Tests\Unit\Channels;

use App\Channels\WebChannel;
use App\Enums\Presence;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class WebChannelTest extends TestCase
{
    public function test_presence_follows_heartbeat(): void
    {
        $user = new User(['login' => 'n', 'tz' => 'UTC']);
        $user->id = 7;
        $driver = new WebChannel;

        $this->assertSame(Presence::Absent, $driver->presence($user, []));
        Cache::put('heartbeat:7', true, 90);
        $this->assertSame(Presence::Present, $driver->presence($user, []));
    }
}

core/tests/Unit/Channels/WebhookChannelTest.php:

<?php

namespace Tests\Unit\Channels;

use App\Channels\WebhookChannel;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;

class WebhookChannelTest extends TestCase
{
    private array $config = ['deliver_url' => 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/hado/presence'];

    protected function setUp(): void
    {
        parent::setUp();
        Route::post('/a/{token}/ack', fn () => '')->name('action.ack');
        Route::post('/a/{token}/done', fn () => '')->name('action.done');
    }

    private function driver(): WebhookChannel
    {
        return new WebhookChannel(2);
    }

    public function test_presence_from_json(): void
    {
        Http::fake([
            'ha.test/api/hado/presence' => Http::sequence()
                ->push(['present' => true])
                ->push(['present' => false])
                ->push('oops', 500),
        ]);
        $user = new User(['login' => 'n', 'tz' => 'UTC']);

        $this->assertSame(Presence::Present, $this->driver()->presence($user, $this->config));
        $this->assertSame(Presence::Absent, $this->driver()->presence($user, $this->config));
        $this->assertSame(Presence::Unknown, $this->driver()->presence($user, $this->config));
    }

    public function test_presence_unknown_on_timeout(): void
    {
        Http::fake(fn () => throw new ConnectionException('timeout'));
        $this->assertSame(Presence::Unknown, $this->driver()->presence(new User(['login' => 'n', 'tz' => 'UTC']), $this->config));
    }

    public function test_deliver_posts_event_and_action_urls(): void
    {
        Http::fake(['ha.test/api/webhook/hado' => Http::response('', 200)]);

        $event = new Event([
            'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14',
            'after_due' => AfterDue::Expire, 'state' => EventState::Today,
            'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'deep_link' => '/people/42', 'done_label' => 'Поздравил'],
        ]);
        $event->id = 17;
        $event->setRelation('source', new Source(['name' => 'docs']));
        $delivery = new Delivery(['action_token' => 'tok123']);

        $outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, $delivery);

        $this->assertTrue($outcome->ok);
        Http::assertSent(function (Request $r) {
            return $r->url() === 'https://ha.test/api/webhook/hado'
                && $r['event']['id'] === 17
                && $r['event']['title'] === 'Сегодня ДР — Вася'
                && $r['event']['done_label'] === 'Поздравил'
                && $r['event']['source'] === 'docs'
                && $r['event']['due_at'] === '2026-08-14'
                && str_ends_with($r['actions']['ack'], '/a/tok123/ack')
                && str_ends_with($r['actions']['done'], '/a/tok123/done');
        });
    }

    public function test_deliver_reports_failure(): void
    {
        Http::fake(['ha.test/api/webhook/hado' => Http::response('nope', 503)]);
        $event = new Event(['due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', 'after_due' => AfterDue::Expire, 'state' => EventState::Today, 'payload' => ['title' => 't']]);
        $event->setRelation('source', new Source(['name' => 'docs']));

        $outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, new Delivery(['action_token' => 'x']));

        $this->assertFalse($outcome->ok);
        $this->assertStringContainsString('503', $outcome->error);
    }
}

core/tests/Unit/Channels/TelegramChannelTest.php:

<?php

namespace Tests\Unit\Channels;

use App\Channels\TelegramChannel;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class TelegramChannelTest extends TestCase
{
    private function driver(): TelegramChannel
    {
        return new TelegramChannel('test-token', 600);
    }

    private function event(): Event
    {
        $event = new Event([
            'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14',
            'after_due' => AfterDue::Expire, 'state' => EventState::Today,
            'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'done_label' => 'Поздравил'],
        ]);
        $event->id = 17;
        $event->setRelation('source', new Source(['name' => 'docs']));

        return $event;
    }

    public function test_presence_is_unknown_unless_recently_seen(): void
    {
        $user = new User(['login' => 'n', 'tz' => 'UTC']);
        $this->assertSame(Presence::Unknown, $this->driver()->presence($user, ['chat_id' => 555]));

        $this->driver()->touch(555);
        $this->assertSame(Presence::Present, $this->driver()->presence($user, ['chat_id' => 555]));
    }

    public function test_deliver_sends_message_with_two_buttons_and_stores_message_id(): void
    {
        Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => ['message_id' => 9001]])]);

        $outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok']));

        $this->assertTrue($outcome->ok);
        $this->assertSame(['message_id' => 9001], $outcome->meta);
        Http::assertSent(function (Request $r) {
            $kb = $r['reply_markup']['inline_keyboard'][0];

            return str_ends_with($r->url(), '/bottest-token/sendMessage')
                && $r['chat_id'] === 555
                && str_contains($r['text'], 'Сегодня ДР — Вася')
                && str_contains($r['text'], 'исполняется 34')
                && $kb[0] === ['text' => 'Помню', 'callback_data' => 'ack:tok']
                && $kb[1] === ['text' => 'Поздравил', 'callback_data' => 'done:tok'];
        });
    }

    public function test_deliver_fails_on_api_error(): void
    {
        Http::fake(['api.telegram.org/*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400)]);

        $outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok']));

        $this->assertFalse($outcome->ok);
        $this->assertStringContainsString('chat not found', $outcome->error);
    }

    public function test_on_done_clears_buttons_best_effort(): void
    {
        Http::fake(['api.telegram.org/*' => Http::response(['ok' => true])]);
        $delivery = new Delivery(['action_token' => 'tok', 'meta' => ['message_id' => 9001]]);
        $delivery->setRelation('channel', new \App\Models\Channel(['config' => ['chat_id' => 555]]));

        $this->driver()->onDone($this->event(), $delivery);

        Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001 && $r['chat_id'] === 555);

        Http::fake(fn () => throw new \RuntimeException('down'));
        $this->driver()->onDone($this->event(), $delivery); // не бросает
        $this->addToAssertionCount(1);
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter "WebChannelTest|WebhookChannelTest|TelegramChannelTest"

Expected: FAIL, classes not found.

  • Step 3: Implement

core/app/Channels/DeliveryOutcome.php:

<?php

namespace App\Channels;

final class DeliveryOutcome
{
    private function __construct(
        public readonly bool $ok,
        public readonly ?string $error,
        /** @var array<string,mixed> служебное канала, ляжет в deliveries.meta */
        public readonly array $meta,
    ) {}

    public static function ok(array $meta = []): self
    {
        return new self(true, null, $meta);
    }

    public static function failed(string $error): self
    {
        return new self(false, $error, []);
    }
}

core/app/Channels/ChannelDriver.php:

<?php

namespace App\Channels;

use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;

interface ChannelDriver
{
    /** Пользователь сейчас доступен через этот канал? */
    public function presence(User $user, array $config): Presence;

    /** Доставить напоминание. Не бросает: ошибки — в DeliveryOutcome::failed. */
    public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome;

    /** Событие закрыто: убрать кнопки и т.п. Best effort, никогда не бросает. */
    public function onDone(Event $event, Delivery $delivery): void;
}

core/app/Channels/ChannelRegistry.php:

<?php

namespace App\Channels;

use App\Enums\ChannelType;
use Illuminate\Contracts\Container\Container;

final class ChannelRegistry
{
    public function __construct(private readonly Container $container) {}

    public function for(ChannelType $type): ChannelDriver
    {
        return match ($type) {
            ChannelType::Web => $this->container->make(WebChannel::class),
            ChannelType::Webhook => $this->container->make(WebhookChannel::class),
            ChannelType::Telegram => $this->container->make(TelegramChannel::class),
        };
    }
}

core/app/Channels/WebChannel.php:

<?php

namespace App\Channels;

use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Facades\Cache;

/** Инбокс. Ничего не шлёт (страница и так показывает всё), но присутствие определяет. */
final class WebChannel implements ChannelDriver
{
    public function presence(User $user, array $config): Presence
    {
        return Cache::has("heartbeat:{$user->id}") ? Presence::Present : Presence::Absent;
    }

    public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
    {
        return DeliveryOutcome::ok();
    }

    public function onDone(Event $event, Delivery $delivery): void {}
}

core/app/Channels/WebhookChannel.php:

<?php

namespace App\Channels;

use App\Enums\Presence;
use App\Http\Controllers\Api\EventsController as Presenter;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Throwable;

/** Универсальный webhook. В нашем случае — Home Assistant. */
final class WebhookChannel implements ChannelDriver
{
    public function __construct(private readonly int $presenceTimeout) {}

    public function presence(User $user, array $config): Presence
    {
        try {
            $response = Http::timeout($this->presenceTimeout)->acceptJson()->get($config['presence_url']);
            if (! $response->successful()) {
                return Presence::Unknown;
            }

            return $response->json('present') === true ? Presence::Present : Presence::Absent;
        } catch (Throwable) {
            return Presence::Unknown;
        }
    }

    public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
    {
        try {
            $response = Http::timeout(5)->asJson()->post($config['deliver_url'], self::payload($event, $delivery));
            if ($response->successful()) {
                return DeliveryOutcome::ok();
            }

            return DeliveryOutcome::failed("HTTP {$response->status()}: ".mb_substr($response->body(), 0, 200));
        } catch (Throwable $e) {
            return DeliveryOutcome::failed($e->getMessage());
        }
    }

    public function onDone(Event $event, Delivery $delivery): void {}

    /** @return array<string,mixed> */
    public static function payload(Event $event, Delivery $delivery): array
    {
        $presented = Presenter::present($event);

        return [
            'event' => [
                'id' => $event->id,
                'state' => $presented['state'],
                'due_at' => $presented['due_at'],
                'source' => $event->source->name,
                'title' => $event->payload['title'],
                'subtitle' => $event->payload['subtitle'] ?? null,
                'deep_link' => $event->payload['deep_link'] ?? null,
                'done_label' => $event->payload['done_label'] ?? 'Сделано',
            ],
            'actions' => [
                'ack' => route('action.ack', $delivery->action_token),
                'done' => route('action.done', $delivery->action_token),
            ],
        ];
    }
}

core/app/Channels/TelegramChannel.php:

<?php

namespace App\Channels;

use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;

final class TelegramChannel implements ChannelDriver
{
    public function __construct(
        private readonly string $botToken,
        private readonly int $seenTtl,
    ) {}

    public function presence(User $user, array $config): Presence
    {
        // Бот не видит онлайн-статус: present только если недавно жал кнопку или писал.
        return Cache::has($this->seenKey($config['chat_id'])) ? Presence::Present : Presence::Unknown;
    }

    public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
    {
        $text = $event->payload['title'];
        if (! empty($event->payload['subtitle'])) {
            $text .= "\n".$event->payload['subtitle'];
        }

        try {
            $response = $this->call('sendMessage', [
                'chat_id' => $config['chat_id'],
                'text' => $text,
                'reply_markup' => ['inline_keyboard' => [[
                    ['text' => 'Помню', 'callback_data' => 'ack:'.$delivery->action_token],
                    ['text' => $event->payload['done_label'] ?? 'Сделано', 'callback_data' => 'done:'.$delivery->action_token],
                ]]],
            ]);
        } catch (Throwable $e) {
            return DeliveryOutcome::failed($e->getMessage());
        }

        if (! $response->successful() || $response->json('ok') !== true) {
            return DeliveryOutcome::failed('Telegram: '.($response->json('description') ?? "HTTP {$response->status()}"));
        }

        return DeliveryOutcome::ok(['message_id' => $response->json('result.message_id')]);
    }

    public function onDone(Event $event, Delivery $delivery): void
    {
        $messageId = $delivery->meta['message_id'] ?? null;
        $chatId = $delivery->channel?->config['chat_id'] ?? null;
        if ($messageId === null || $chatId === null) {
            return;
        }
        try {
            $this->call('editMessageReplyMarkup', ['chat_id' => $chatId, 'message_id' => $messageId, 'reply_markup' => ['inline_keyboard' => []]]);
        } catch (Throwable) {
            // best effort
        }
    }

    public function touch(int|string $chatId): void
    {
        Cache::put($this->seenKey($chatId), true, $this->seenTtl);
    }

    public function sendText(int|string $chatId, string $text): void
    {
        try {
            $this->call('sendMessage', ['chat_id' => $chatId, 'text' => $text]);
        } catch (Throwable) {
            // best effort
        }
    }

    public function answerCallback(string $callbackId, string $text): void
    {
        try {
            $this->call('answerCallbackQuery', ['callback_query_id' => $callbackId, 'text' => $text]);
        } catch (Throwable) {
            // best effort
        }
    }

    private function call(string $method, array $params): Response
    {
        return Http::timeout(5)->asJson()->post("https://api.telegram.org/bot{$this->botToken}/{$method}", $params);
    }

    private function seenKey(int|string $chatId): string
    {
        return "tg:seen:{$chatId}";
    }
}

Bindings in core/app/Providers/AppServiceProvider.php register() (next to the WindowResolver binding):

$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():

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
cd core && php artisan test --filter "WebChannelTest|WebhookChannelTest|TelegramChannelTest|MeApiTest"

Expected: all PASS (MeApiTest still green after the EventActions constructor change).

  • Step 5: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web, webhook and telegram channel drivers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 9: Tick, DeliverJob, scheduler, purge

Files:

  • Create: core/app/Delivery/Tick.php, core/app/Delivery/DeliverJob.php, core/app/Delivery/DeliveryFailed.php, core/app/Console/Commands/TickCommand.php, core/app/Console/Commands/PurgeCommand.php
  • Modify: core/routes/console.php (schedule)
  • Test: core/tests/Feature/TickTest.php, core/tests/Feature/DeliverJobTest.php, core/tests/Feature/PurgeCommandTest.php

Interfaces:

  • Consumes: StateResolver, DueResolver, WindowResolver, ChannelRegistry, models.

  • Produces:

    • Tick::run(): void — one pass over all non-terminal events.
    • DeliverJob::dispatch(int $deliveryId); $tries = 3, backoff() = [30, 120, 600]; marks ok/failed.
    • artisan hado:tick (every minute), hado:purge (daily at 04:00).
    • Presence cache key presence:{channel_id}, TTL hado.presence_ttl.
    • Fake driver for tests: Tests\Support\FakeChannel (in core/tests/Support/FakeChannel.php), bound over ChannelRegistry via a Tests\Support\FakeRegistry.
  • Step 1: Test support classes

core/tests/Support/FakeChannel.php:

<?php

namespace Tests\Support;

use App\Channels\ChannelDriver;
use App\Channels\DeliveryOutcome;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;

final class FakeChannel implements ChannelDriver
{
    /** @var list<int> event ids delivered */
    public array $delivered = [];

    public function __construct(public Presence $presence = Presence::Unknown, public bool $succeed = true) {}

    public function presence(User $user, array $config): Presence
    {
        return $this->presence;
    }

    public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome
    {
        $this->delivered[] = $event->id;

        return $this->succeed ? DeliveryOutcome::ok(['n' => count($this->delivered)]) : DeliveryOutcome::failed('boom');
    }

    public function onDone(Event $event, Delivery $delivery): void {}
}

core/tests/Support/FakeRegistry.php:

<?php

namespace Tests\Support;

use App\Channels\ChannelDriver;
use App\Channels\ChannelRegistry;
use App\Enums\ChannelType;
use Illuminate\Container\Container;

final class FakeRegistry extends ChannelRegistry
{
    /** @param array<string, ChannelDriver> $drivers keyed by ChannelType value */
    public function __construct(private readonly array $drivers)
    {
        parent::__construct(Container::getInstance());
    }

    public function for(ChannelType $type): ChannelDriver
    {
        return $this->drivers[$type->value];
    }
}

Add "Tests\\Support\\": "tests/Support/" is unnecessary: Laravel's composer.json autoloads Tests\\ from tests/ already.

  • Step 2: Failing tests

core/tests/Feature/TickTest.php:

<?php

namespace Tests\Feature;

use App\Channels\ChannelRegistry;
use App\Delivery\Tick;
use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\DeliveryResult;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\Support\FakeChannel;
use Tests\Support\FakeRegistry;
use Tests\TestCase;

class TickTest extends TestCase
{
    use RefreshDatabase;

    private User $user;
    private Source $source;
    private FakeChannel $web;
    private FakeChannel $telegram;
    private FakeChannel $webhook;

    protected function setUp(): void
    {
        parent::setUp();
        $this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $this->user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Web, 'config' => [], 'enabled' => true]);
        Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Telegram, 'config' => ['chat_id' => 1], 'enabled' => true]);
        Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);

        $this->web = new FakeChannel(Presence::Absent);
        $this->telegram = new FakeChannel(Presence::Unknown);
        $this->webhook = new FakeChannel(Presence::Absent);
        $this->app->instance(ChannelRegistry::class, new FakeRegistry([
            'web' => $this->web, 'telegram' => $this->telegram, 'webhook' => $this->webhook,
        ]));
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    private function event(string $due, string $fireOn, AfterDue $afterDue = AfterDue::Keep, string $time = '24:00:00'): Event
    {
        return Event::create([
            'source_id' => $this->source->id, 'user_id' => $this->user->id, 'source_ref' => 'r', 'topic' => uniqid(),
            'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn,
            'after_due' => $afterDue, 'payload' => ['title' => 'T'], 'state' => EventState::Scheduled,
            'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]);
    }

    private function tickAt(string $utc): void
    {
        CarbonImmutable::setTestNow($utc);
        Cache::flush();
        app(Tick::class)->run();
    }

    public function test_moves_states_without_delivering_before_windows(): void
    {
        $e = $this->event('2026-09-30', '2026-08-31');

        $this->tickAt('2026-08-31T03:00:00Z');
        $this->assertSame(EventState::Preparing, $e->fresh()->state);
        $this->assertSame(0, Delivery::count(), 'тихие часы — доставок нет');

        $this->tickAt('2026-10-01T00:00:00Z');
        $this->assertSame(EventState::Overdue, $e->fresh()->state);
    }

    public function test_delivers_only_to_present_channels_and_once_per_window(): void
    {
        $e = $this->event('2026-09-30', '2026-08-31');
        $this->webhook->presence = Presence::Present;

        $this->tickAt('2026-09-05T10:00:00Z');
        $deliveries = Delivery::all();
        $this->assertCount(1, $deliveries);
        $this->assertSame(ChannelType::Webhook, $deliveries->sole()->channel->type);
        $this->assertSame(DeliveryResult::Ok, $deliveries->sole()->result, 'sync-очередь: job уже отработал');
        $this->assertSame('2026-09-05T09:00:00+00:00', $deliveries->sole()->window_start->toIso8601String());

        $this->tickAt('2026-09-05T15:00:00Z');
        $this->assertSame(1, Delivery::count(), 'второй раз в то же окно не шлём');

        $this->tickAt('2026-09-06T10:00:00Z');
        $this->assertSame(2, Delivery::count(), 'новое окно — новая доставка');
    }

    public function test_falls_back_to_all_channels_near_window_end(): void
    {
        $this->event('2026-09-30', '2026-08-31');

        $this->tickAt('2026-09-05T21:00:00Z');
        $this->assertSame(0, Delivery::count(), 'никто не present, до fallback ещё далеко');

        $this->tickAt('2026-09-05T21:31:00Z');
        $this->assertSame(3, Delivery::count(), 'fallback во все три канала');
        $this->assertSame([1, 1, 1], [count($this->web->delivered), count($this->telegram->delivered), count($this->webhook->delivered)]);
    }

    public function test_today_fallback_limited_to_listed_hours(): void
    {
        $this->event('2026-09-30', '2026-09-30');

        $this->tickAt('2026-09-30T11:55:00Z');
        $this->assertSame(0, Delivery::count(), '11:00 не fallback-час');

        $this->tickAt('2026-09-30T14:55:00Z');
        $this->assertSame(3, Delivery::count(), '14:00 — fallback-час');
    }

    public function test_today_hourly_when_present(): void
    {
        $this->event('2026-09-30', '2026-09-30');
        $this->web->presence = Presence::Present;

        $this->tickAt('2026-09-30T11:05:00Z');
        $this->tickAt('2026-09-30T11:45:00Z');
        $this->tickAt('2026-09-30T12:05:00Z');
        $this->assertSame(2, Delivery::count());
        $this->assertSame(0, count($this->telegram->delivered), 'в telegram не шли: web present');
    }

    public function test_quiet_until_blocks_delivery(): void
    {
        $e = $this->event('2026-09-30', '2026-08-31');
        $e->update(['quiet_until' => CarbonImmutable::parse('2026-09-15T00:00:00Z')]);
        $this->webhook->presence = Presence::Present;

        $this->tickAt('2026-09-10T10:00:00Z');
        $this->assertSame(0, Delivery::count());

        $this->tickAt('2026-09-15T10:00:00Z');
        $this->assertSame(1, Delivery::count());
    }

    public function test_disabled_channel_is_skipped_and_user_without_channels_is_fine(): void
    {
        Channel::where('type', ChannelType::Webhook->value)->update(['enabled' => false]);
        $this->webhook->presence = Presence::Present;
        $this->event('2026-09-30', '2026-08-31');

        $this->tickAt('2026-09-05T21:31:00Z');
        $this->assertSame(2, Delivery::count(), 'fallback только в включённые');

        Channel::query()->delete();
        $this->tickAt('2026-09-06T21:31:00Z');
        $this->assertSame(2, Delivery::count());
    }

    public function test_user_timezone_shifts_windows(): void
    {
        $this->user->update(['tz' => 'Asia/Shanghai']); // UTC+8
        $this->event('2026-09-30', '2026-08-31');
        $this->webhook->presence = Presence::Present;

        $this->tickAt('2026-09-05T00:30:00Z'); // 08:30 в Шанхае — тихие часы
        $this->assertSame(0, Delivery::count());
        $this->tickAt('2026-09-05T01:30:00Z'); // 09:30 в Шанхае
        $this->assertSame(1, Delivery::count());
    }

    public function test_presence_is_cached_for_a_minute(): void
    {
        $this->event('2026-09-30', '2026-08-31');
        $this->webhook->presence = Presence::Present;

        CarbonImmutable::setTestNow('2026-09-05T10:00:00Z');
        app(Tick::class)->run();
        $this->webhook->presence = Presence::Absent;
        $this->assertSame(Presence::Present, Cache::get('presence:'.Channel::where('type', 'webhook')->sole()->id));
    }
}

core/tests/Feature/DeliverJobTest.php:

<?php

namespace Tests\Feature;

use App\Channels\ChannelRegistry;
use App\Delivery\DeliverJob;
use App\Delivery\DeliveryFailed;
use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\DeliveryResult;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\FakeChannel;
use Tests\Support\FakeRegistry;
use Tests\TestCase;

class DeliverJobTest extends TestCase
{
    use RefreshDatabase;

    private Delivery $delivery;
    private FakeChannel $driver;

    protected function setUp(): void
    {
        parent::setUp();
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $channel = Channel::create(['user_id' => $user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
        $event = Event::create([
            'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => '',
            'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-08-31',
            'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Preparing,
            'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]);
        $this->delivery = Delivery::create([
            'event_id' => $event->id, 'channel_id' => $channel->id, 'window_start' => CarbonImmutable::parse('2026-09-05T09:00:00Z'),
            'action_token' => 'tok', 'result' => DeliveryResult::Pending,
        ]);
        $this->driver = new FakeChannel;
        $this->app->instance(ChannelRegistry::class, new FakeRegistry(['webhook' => $this->driver]));
    }

    public function test_success_marks_ok_with_meta(): void
    {
        (new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));

        $d = $this->delivery->fresh();
        $this->assertSame(DeliveryResult::Ok, $d->result);
        $this->assertNotNull($d->sent_at);
        $this->assertSame(['n' => 1], $d->meta);
    }

    public function test_failure_records_error_and_throws_for_retry(): void
    {
        $this->driver->succeed = false;

        $this->expectException(DeliveryFailed::class);
        try {
            (new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
        } finally {
            $d = $this->delivery->fresh();
            $this->assertSame(DeliveryResult::Pending, $d->result, 'ещё pending — будут ретраи');
            $this->assertSame('boom', $d->error);
        }
    }

    public function test_failed_hook_marks_failed(): void
    {
        (new DeliverJob($this->delivery->id))->failed(new DeliveryFailed('boom'));
        $this->assertSame(DeliveryResult::Failed, $this->delivery->fresh()->result);
    }

    public function test_skips_when_already_done_or_event_terminal(): void
    {
        $this->delivery->update(['result' => DeliveryResult::Ok]);
        (new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
        $this->assertSame([], $this->driver->delivered);

        $this->delivery->update(['result' => DeliveryResult::Pending]);
        $this->delivery->event->update(['state' => EventState::Done]);
        (new DeliverJob($this->delivery->id))->handle(app(ChannelRegistry::class));
        $this->assertSame([], $this->driver->delivered);
        $this->assertSame(DeliveryResult::Failed, $this->delivery->fresh()->result);
    }
}

core/tests/Feature/PurgeCommandTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PurgeCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_deletes_old_terminal_events_only(): void
    {
        config(['hado.retention_days' => 90]);
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $user = User::create(['login' => 'n', 'tz' => 'UTC']);
        $make = fn (string $topic, EventState $state, string $updatedAt) => tap(Event::create([
            'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
            'due_mode' => DueMode::Local, 'due_date' => '2026-01-01', 'due_time' => '24:00:00', 'fire_on' => '2026-01-01',
            'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => $state,
            'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]), fn (Event $e) => Event::whereKey($e->id)->update(['updated_at' => $updatedAt]));

        CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
        $make('old-done', EventState::Done, '2026-05-01 00:00:00');
        $make('old-expired', EventState::Expired, '2026-05-01 00:00:00');
        $make('fresh-done', EventState::Done, '2026-08-01 00:00:00');
        $make('old-live', EventState::Overdue, '2026-05-01 00:00:00');

        $this->artisan('hado:purge')->assertSuccessful();

        $this->assertSame(['fresh-done', 'old-live'], Event::orderBy('topic')->pluck('topic')->all());
        CarbonImmutable::setTestNow();
    }
}
  • Step 3: Run to verify failure
cd core && php artisan test --filter "TickTest|DeliverJobTest|PurgeCommandTest"

Expected: FAIL, classes not found.

  • Step 4: Implement

core/app/Delivery/DeliveryFailed.php:

<?php

namespace App\Delivery;

use RuntimeException;

final class DeliveryFailed extends RuntimeException {}

core/app/Delivery/DeliverJob.php:

<?php

namespace App\Delivery;

use App\Channels\ChannelRegistry;
use App\Enums\DeliveryResult;
use App\Models\Delivery;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;

final class DeliverJob implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function __construct(public readonly int $deliveryId) {}

    /** @return list<int> */
    public function backoff(): array
    {
        return [30, 120, 600];
    }

    public function handle(ChannelRegistry $registry): void
    {
        $delivery = Delivery::with(['event.user', 'event.source', 'channel'])->findOrFail($this->deliveryId);
        if ($delivery->result !== DeliveryResult::Pending) {
            return;
        }

        if ($delivery->event->isTerminal()) {
            $delivery->update(['result' => DeliveryResult::Failed, 'error' => 'skipped: событие уже закрыто']);

            return;
        }

        $outcome = $registry->for($delivery->channel->type)
            ->deliver($delivery->event, $delivery->event->user, $delivery->channel->config, $delivery);

        if ($outcome->ok) {
            $delivery->update(['result' => DeliveryResult::Ok, 'sent_at' => now(), 'error' => null, 'meta' => $outcome->meta]);

            return;
        }

        $delivery->update(['error' => $outcome->error]);
        throw new DeliveryFailed($outcome->error ?? 'delivery failed');
    }

    public function failed(Throwable $e): void
    {
        Delivery::whereKey($this->deliveryId)
            ->where('result', DeliveryResult::Pending->value)
            ->update(['result' => DeliveryResult::Failed, 'error' => $e->getMessage()]);
    }
}

core/app/Delivery/Tick.php:

<?php

namespace App\Delivery;

use App\Channels\ChannelRegistry;
use App\Enums\DeliveryResult;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use App\Scheduling\StateResolver;
use App\Scheduling\Window;
use App\Scheduling\WindowResolver;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * Один тик планировщика (раз в минуту): переходы состояний по датам и решение,
 * доставлять ли напоминание в открытое окно и куда.
 */
final class Tick
{
    public function __construct(
        private readonly DueResolver $due,
        private readonly StateResolver $states,
        private readonly WindowResolver $windows,
        private readonly ChannelRegistry $registry,
        private readonly Cache $cache,
        private readonly int $presenceTtl,
    ) {}

    public function run(): void
    {
        Event::query()
            ->whereIn('state', EventState::nonTerminalValues())
            ->with('user')
            ->chunkById(200, fn (Collection $events) => $events->each(fn (Event $e) => $this->process($e)));
    }

    private function process(Event $event): void
    {
        $user = $event->user;
        $now = $user->now();

        $state = $this->states->resolve($event, $user, $now);
        if ($state !== $event->state) {
            $event->state = $state;
            $event->save();
        }
        if (! $state->isActive() || $now->lt($event->quiet_until)) {
            return;
        }

        $window = $this->windows->current($state, $user, $this->due->resolve($event, $user), $now);
        if ($window === null) {
            return;
        }
        if (Delivery::where('event_id', $event->id)->where('window_start', $window->start->utc())->exists()) {
            return;
        }

        $channels = $user->channels()->where('enabled', true)->get();
        if ($channels->isEmpty()) {
            return;
        }

        $present = $channels->filter(fn (Channel $c) => $this->presence($c, $user) === Presence::Present);
        if ($present->isNotEmpty()) {
            $this->dispatch($event, $present, $window);

            return;
        }
        if ($window->fallbackAllowed && $now->gte($window->fallbackAt)) {
            $this->dispatch($event, $channels, $window);
        }
    }

    private function presence(Channel $channel, User $user): Presence
    {
        return $this->cache->remember(
            "presence:{$channel->id}",
            $this->presenceTtl,
            fn () => $this->registry->for($channel->type)->presence($user, $channel->config),
        );
    }

    /** Строка pending — в транзакции ДО постановки job-а: следующий тик её уже видит. */
    private function dispatch(Event $event, Collection $channels, Window $window): void
    {
        DB::transaction(function () use ($event, $channels, $window) {
            foreach ($channels as $channel) {
                $delivery = Delivery::create([
                    'event_id' => $event->id,
                    'channel_id' => $channel->id,
                    'window_start' => $window->start->utc(),
                    'action_token' => Str::random(48),
                    'result' => DeliveryResult::Pending,
                ]);
                DeliverJob::dispatch($delivery->id)->afterCommit();
            }
        });
    }
}

Bind in AppServiceProvider::register():

$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

namespace App\Console\Commands;

use App\Delivery\Tick;
use Illuminate\Console\Command;

class TickCommand extends Command
{
    protected $signature = 'hado:tick';

    protected $description = 'Один тик: переходы состояний и доставка напоминаний в открытые окна';

    public function handle(Tick $tick): int
    {
        $tick->run();

        return self::SUCCESS;
    }
}

core/app/Console/Commands/PurgeCommand.php:

<?php

namespace App\Console\Commands;

use App\Enums\EventState;
use App\Models\Event;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;

class PurgeCommand extends Command
{
    protected $signature = 'hado:purge';

    protected $description = 'Удаляет терминальные события старше HADO_RETENTION_DAYS';

    public function handle(): int
    {
        $terminal = array_map(fn (EventState $s) => $s->value, array_filter(EventState::cases(), fn (EventState $s) => $s->isTerminal()));
        $cutoff = CarbonImmutable::now()->subDays((int) config('hado.retention_days'));

        $deleted = Event::whereIn('state', $terminal)->where('updated_at', '<', $cutoff)->delete();
        $this->info("Удалено событий: {$deleted}");

        return self::SUCCESS;
    }
}

core/routes/console.php (replace content):

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('hado:tick')->everyMinute()->withoutOverlapping()->runInBackground();
Schedule::command('hado:purge')->dailyAt('04:00');
  • Step 5: Run tests
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
cd /d/ai-projects/hado && git add -A && git commit -m "feat: tick planner, delivery job, scheduler and purge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

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}/ackaction.ack, POST /a/{token}/doneaction.done. 404 unknown token, 410 if the event is terminal, 200 {state} otherwise. No CSRF, no SSO.
    • POST /hooks/telegram — checks header X-Telegram-Bot-Api-Secret-Token against hado.telegram.webhook_secret (403 otherwise). Handles /start <code> linking and callback_query with ack:{token} / done:{token}.
    • GET /me/channels, POST /me/channels {type: webhook, config: {deliver_url, presence_url}}, DELETE /me/channels/{id} (web channel is not deletable → 422), POST /me/channels/telegram/link{code, bot_url}. Link code cache key tg:link:{code} → user id, TTL 15 min.
  • Step 1: Failing tests

core/tests/Feature/ActionLinksTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\DeliveryResult;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ActionLinksTest extends TestCase
{
    use RefreshDatabase;

    private Event $event;

    protected function setUp(): void
    {
        parent::setUp();
        CarbonImmutable::setTestNow('2026-09-30T11:17:00Z');
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
        $channel = Channel::create(['user_id' => $user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
        $this->event = Event::create([
            'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => '',
            'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-09-30',
            'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Today,
            'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]);
        Delivery::create(['event_id' => $this->event->id, 'channel_id' => $channel->id, 'window_start' => now(), 'action_token' => 'tok', 'result' => DeliveryResult::Ok]);
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    public function test_ack_by_token(): void
    {
        $this->postJson('/a/tok/ack')->assertOk()->assertJsonPath('state', 'today');
        $this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String());
    }

    public function test_done_by_token_then_gone(): void
    {
        $this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done');
        $this->assertSame(EventState::Done, $this->event->fresh()->state);

        $this->postJson('/a/tok/done')->assertStatus(410);
        $this->postJson('/a/tok/ack')->assertStatus(410);
    }

    public function test_unknown_token(): void
    {
        $this->postJson('/a/nope/done')->assertNotFound();
    }
}

core/tests/Feature/TelegramWebhookTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\DeliveryResult;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class TelegramWebhookTest extends TestCase
{
    use RefreshDatabase;

    private User $user;

    protected function setUp(): void
    {
        parent::setUp();
        Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => []])]);
        $this->user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
    }

    private function secret(): array
    {
        return ['X-Telegram-Bot-Api-Secret-Token' => 'test-secret'];
    }

    public function test_rejects_wrong_secret(): void
    {
        $this->postJson('/hooks/telegram', ['message' => []])->assertStatus(403);
        $this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403);
    }

    public function test_start_with_code_links_chat(): void
    {
        Cache::put('tg:link:ABC123', $this->user->id, 900);

        $this->postJson('/hooks/telegram', ['message' => ['chat' => ['id' => 555], 'text' => '/start ABC123']], $this->secret())->assertNoContent();

        $channel = $this->user->channels()->where('type', ChannelType::Telegram->value)->sole();
        $this->assertSame(555, $channel->config['chat_id']);
        $this->assertFalse(Cache::has('tg:link:ABC123'), 'код одноразовый');
        $this->assertTrue(Cache::has('tg:seen:555'), 'взаимодействие = присутствие');
        Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/sendMessage') && $r['text'] === 'Подключено');
    }

    public function test_start_with_bad_code_replies_error(): void
    {
        $this->postJson('/hooks/telegram', ['message' => ['chat' => ['id' => 555], 'text' => '/start NOPE']], $this->secret())->assertNoContent();
        $this->assertSame(0, Channel::count());
        Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/sendMessage') && str_contains($r['text'], 'Код'));
    }

    public function test_callback_ack_and_done(): void
    {
        CarbonImmutable::setTestNow('2026-09-30T11:17:00Z');
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $channel = Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Telegram, 'config' => ['chat_id' => 555], 'enabled' => true]);
        $event = Event::create([
            'source_id' => $source->id, 'user_id' => $this->user->id, 'source_ref' => 'r', 'topic' => '',
            'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-09-30',
            'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Today,
            'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]);
        Delivery::create(['event_id' => $event->id, 'channel_id' => $channel->id, 'window_start' => now(), 'action_token' => 'tok', 'result' => DeliveryResult::Ok, 'meta' => ['message_id' => 9001]]);

        $cb = fn (string $data) => ['callback_query' => ['id' => 'cb1', 'data' => $data, 'message' => ['chat' => ['id' => 555], 'message_id' => 9001]]];

        $this->postJson('/hooks/telegram', $cb('ack:tok'), $this->secret())->assertNoContent();
        $this->assertSame('2026-09-30T13:00:00+00:00', $event->fresh()->quiet_until->toIso8601String());
        Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/answerCallbackQuery') && $r['callback_query_id'] === 'cb1');

        $this->postJson('/hooks/telegram', $cb('done:tok'), $this->secret())->assertNoContent();
        $this->assertSame(EventState::Done, $event->fresh()->state);
        Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001);

        $this->postJson('/hooks/telegram', $cb('done:tok'), $this->secret())->assertNoContent();
        Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/answerCallbackQuery') && $r['text'] === 'Уже неактуально');

        CarbonImmutable::setTestNow();
    }

    public function test_unrelated_update_is_ignored(): void
    {
        $this->postJson('/hooks/telegram', ['edited_message' => ['chat' => ['id' => 1]]], $this->secret())->assertNoContent();
    }
}

core/tests/Feature/ChannelsApiTest.php:

<?php

namespace Tests\Feature;

use App\Enums\ChannelType;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class ChannelsApiTest extends TestCase
{
    use RefreshDatabase;

    private array $as = ['X-Remote-User' => 'nikita'];

    public function test_lists_channels_including_auto_web(): void
    {
        $this->getJson('/me/channels', $this->as)->assertOk()
            ->assertJsonCount(1, 'channels')
            ->assertJsonPath('channels.0.type', 'web');
    }

    public function test_adds_and_deletes_webhook_channel(): void
    {
        $r = $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/presence']], $this->as)
            ->assertCreated()->assertJsonPath('type', 'webhook');

        $this->deleteJson('/me/channels/'.$r->json('id'), [], $this->as)->assertNoContent();
        $this->assertSame(1, Channel::count());
    }

    public function test_validates_webhook_config(): void
    {
        $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'not a url']], $this->as)
            ->assertStatus(422)->assertJsonValidationErrors(['config.deliver_url', 'config.presence_url']);
        // telegram подключается только через /start, не через API
        $this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as)
            ->assertStatus(422);
    }

    public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void
    {
        $this->getJson('/me', $this->as);
        $web = User::sole()->channels()->sole();
        $this->deleteJson('/me/channels/'.$web->id, [], $this->as)->assertStatus(422);

        $this->getJson('/me', ['X-Remote-User' => 'other']);
        $foreign = Channel::create(['user_id' => User::where('login', 'other')->sole()->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]);
        $this->deleteJson('/me/channels/'.$foreign->id, [], $this->as)->assertNotFound();
    }

    public function test_telegram_link_code(): void
    {
        $r = $this->postJson('/me/channels/telegram/link', [], $this->as)->assertOk();
        $code = $r->json('code');

        $this->assertSame(6, strlen($code));
        $this->assertSame('https://t.me/hado_test_bot?start='.$code, $r->json('bot_url'));
        $this->assertSame(User::sole()->id, Cache::get("tg:link:$code"));
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter "ActionLinksTest|TelegramWebhookTest|ChannelsApiTest"

Expected: FAIL (404s).

  • Step 3: Implement

core/app/Http/Controllers/ActionController.php:

<?php

namespace App\Http\Controllers;

use App\Delivery\EventActions;
use App\Models\Delivery;
use App\Models\Event;
use Illuminate\Http\JsonResponse;

/** Кнопки из каналов: подписанные ссылки по action_token доставки. Без SSO и CSRF. */
class ActionController extends Controller
{
    public function __construct(private readonly EventActions $actions) {}

    public function ack(string $token): JsonResponse
    {
        $event = $this->eventFor($token);
        $this->actions->ack($event);

        return response()->json(['state' => $event->fresh()->state->value]);
    }

    public function done(string $token): JsonResponse
    {
        $event = $this->eventFor($token);
        $this->actions->done($event);

        return response()->json(['state' => $event->fresh()->state->value]);
    }

    private function eventFor(string $token): Event
    {
        $delivery = Delivery::where('action_token', $token)->with('event.user')->first();
        abort_if($delivery === null, 404, 'Неизвестный токен');
        abort_if($delivery->event->isTerminal(), 410, 'Событие уже закрыто');

        return $delivery->event;
    }
}

core/app/Http/Controllers/TelegramWebhookController.php:

<?php

namespace App\Http\Controllers;

use App\Channels\TelegramChannel;
use App\Delivery\EventActions;
use App\Enums\ChannelType;
use App\Models\Channel;
use App\Models\Delivery;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;

class TelegramWebhookController extends Controller
{
    public function __construct(
        private readonly TelegramChannel $telegram,
        private readonly EventActions $actions,
    ) {}

    public function __invoke(Request $request): Response
    {
        $expected = (string) config('hado.telegram.webhook_secret');
        $given = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
        abort_unless($expected !== '' && hash_equals($expected, $given), 403);

        if (is_array($message = $request->input('message'))) {
            $this->message($message);
        }
        if (is_array($callback = $request->input('callback_query'))) {
            $this->callback($callback);
        }

        return response()->noContent();
    }

    private function message(array $message): void
    {
        $chatId = $message['chat']['id'] ?? null;
        if ($chatId === null) {
            return;
        }
        $this->telegram->touch($chatId);

        if (! preg_match('~^/start\s+(\S+)~', (string) ($message['text'] ?? ''), $m)) {
            return;
        }

        $userId = Cache::pull('tg:link:'.$m[1]);
        if ($userId === null) {
            $this->telegram->sendText($chatId, 'Код не найден или устарел. Сгенерируй новый в инбоксе Hado.');

            return;
        }

        Channel::updateOrCreate(
            ['user_id' => $userId, 'type' => ChannelType::Telegram->value],
            ['config' => ['chat_id' => $chatId], 'enabled' => true],
        );
        $this->telegram->sendText($chatId, 'Подключено');
    }

    private function callback(array $callback): void
    {
        $chatId = $callback['message']['chat']['id'] ?? null;
        if ($chatId !== null) {
            $this->telegram->touch($chatId);
        }

        [$action, $token] = array_pad(explode(':', (string) ($callback['data'] ?? ''), 2), 2, '');
        $delivery = Delivery::where('action_token', $token)->with('event.user')->first();

        if ($delivery === null || $delivery->event->isTerminal() || ! in_array($action, ['ack', 'done'], true)) {
            $this->telegram->answerCallback((string) $callback['id'], 'Уже неактуально');

            return;
        }

        if ($action === 'done') {
            $this->actions->done($delivery->event);
            $this->telegram->answerCallback((string) $callback['id'], 'Закрыто');
        } else {
            $this->actions->ack($delivery->event);
            $this->telegram->answerCallback((string) $callback['id'], 'Ок, напомню позже');
        }
    }
}

core/app/Http/Controllers/Me/ChannelsController.php:

<?php

namespace App\Http\Controllers\Me;

use App\Enums\ChannelType;
use App\Http\Controllers\Controller;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class ChannelsController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->attributes->get('user');

        return response()->json(['channels' => $user->channels()->orderBy('id')->get()->map(self::present(...))->values()]);
    }

    public function store(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->attributes->get('user');

        $data = $request->validate([
            'type' => ['required', 'in:webhook'],
            'config' => ['required', 'array'],
            'config.deliver_url' => ['required', 'url'],
            'config.presence_url' => ['required', 'url'],
        ], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']);

        $channel = $user->channels()->create([
            'type' => ChannelType::Webhook,
            'config' => ['deliver_url' => $data['config']['deliver_url'], 'presence_url' => $data['config']['presence_url']],
            'enabled' => true,
        ]);

        return response()->json(self::present($channel), 201);
    }

    public function destroy(Request $request, int $channel): Response
    {
        /** @var User $user */
        $user = $request->attributes->get('user');
        $model = $user->channels()->whereKey($channel)->firstOrFail();
        if ($model->type === ChannelType::Web) {
            throw ValidationException::withMessages(['channel' => 'Веб-канал удалить нельзя']);
        }
        $model->delete();

        return response()->noContent();
    }

    public function telegramLink(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->attributes->get('user');
        $code = Str::upper(Str::random(6));
        Cache::put("tg:link:{$code}", $user->id, 900);

        return response()->json([
            'code' => $code,
            'bot_url' => 'https://t.me/'.config('hado.telegram.username').'?start='.$code,
        ]);
    }

    /** @return array<string,mixed> */
    public static function present(Channel $channel): array
    {
        return [
            'id' => $channel->id,
            'type' => $channel->type->value,
            'config' => $channel->config,
            'enabled' => $channel->enabled,
        ];
    }
}

core/routes/web.php — add the public routes and the channel routes (full file):

<?php

use App\Http\Controllers\ActionController;
use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\EventsController as MeEvents;
use App\Http\Controllers\Me\ProfileController;
use App\Http\Controllers\TelegramWebhookController;
use Illuminate\Support\Facades\Route;

// Публичные: без SSO, без CSRF (см. bootstrap/app.php)
Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack');
Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done');
Route::post('/hooks/telegram', TelegramWebhookController::class);

// Под SSO хаба
Route::middleware('remote.user')->group(function () {
    Route::get('/me', [ProfileController::class, 'show']);
    Route::patch('/me', [ProfileController::class, 'update']);
    Route::post('/me/heartbeat', [ProfileController::class, 'heartbeat']);
    Route::get('/me/events', [MeEvents::class, 'index']);
    Route::post('/me/events/{event}/ack', [MeEvents::class, 'ack']);
    Route::post('/me/events/{event}/done', [MeEvents::class, 'done']);
    Route::get('/me/channels', [ChannelsController::class, 'index']);
    Route::post('/me/channels', [ChannelsController::class, 'store']);
    Route::delete('/me/channels/{channel}', [ChannelsController::class, 'destroy']);
    Route::post('/me/channels/telegram/link', [ChannelsController::class, 'telegramLink']);
});
  • Step 4: Run tests
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
cd /d/ai-projects/hado && git add -A && git commit -m "feat: action links, telegram webhook and channels API

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 11: Manual events and archive API

Design handoff (docs/design/handoff/README.md) added two features the spec did not have: manual event creation («+ Добавить») and an archive of closed events («書庫 Архив · N закрыто»). Both stay. Backend support goes here; the page uses it in Task 12.

Files:

  • Modify: core/app/Http/Controllers/Me/EventsController.php (add store, archive)
  • Modify: core/routes/web.php (add POST /me/events, GET /me/archive)
  • Test: core/tests/Feature/ManualEventsTest.php

Interfaces:

  • Consumes: EventUpserter::upsert(Source, items), ApiPresenter::present.

  • Produces:

    • POST /me/events {title, subtitle?, due_date (Y-m-d), due_time? (H:i)} → 201 with the presented event. Source is the reserved manual source (Source::firstOrCreate(['name' => 'manual']) with an unusable random token hash). source_ref = "manual:<ulid>", topic = "", after_due = keep, fire_at = due_date 30 days (so a manual event is «Грядёт» within 30 days and «Запланировано» before that, exactly as the prototype groups it), payload {title, subtitle?, done_label: "Сделано"}.
    • GET /me/archive{events: [...]} — the user's done and expired events, newest closed first (updated_at desc), max 100, each presented plus closed_as: done_label (default «Сделано») for done, «Истекло» for expired. withdrawn is not shown.
    • present() gains source for /me responses (already added in index); manual events show source = "manual"; the page renders it as «вручную».
  • Step 1: Failing test

core/tests/Feature/ManualEventsTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ManualEventsTest extends TestCase
{
    use RefreshDatabase;

    private array $as = ['X-Remote-User' => 'nikita'];

    protected function setUp(): void
    {
        parent::setUp();
        config(['hado.default_tz' => 'UTC']);
        CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    public function test_creates_manual_event_grouped_by_date(): void
    {
        $this->postJson('/me/events', ['title' => 'Продлить страховку', 'subtitle' => 'раз в год', 'due_date' => '2026-09-20'], $this->as)
            ->assertCreated()
            ->assertJsonPath('state', 'preparing')
            ->assertJsonPath('source', 'manual')
            ->assertJsonPath('payload.title', 'Продлить страховку')
            ->assertJsonPath('payload.done_label', 'Сделано')
            ->assertJsonPath('fire_at', '2026-08-21');

        $this->postJson('/me/events', ['title' => 'Далеко', 'due_date' => '2026-12-15'], $this->as)->assertJsonPath('state', 'scheduled');
        $this->postJson('/me/events', ['title' => 'Сегодня', 'due_date' => '2026-09-04', 'due_time' => '19:40'], $this->as)
            ->assertJsonPath('state', 'today')->assertJsonPath('due_at', '2026-09-04T19:40');
        $this->postJson('/me/events', ['title' => 'Прошло', 'due_date' => '2026-09-01'], $this->as)->assertJsonPath('state', 'overdue');

        $this->assertSame(4, Event::count());
        $this->assertSame('manual', Source::sole()->name);
        $this->assertSame(AfterDue::Keep, Event::first()->after_due);
        $this->assertSame(DueMode::Local, Event::first()->due_mode);
    }

    public function test_validation(): void
    {
        $this->postJson('/me/events', ['due_date' => '2026-09-20'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['title']);
        $this->postJson('/me/events', ['title' => 'x'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_date']);
        $this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20', 'due_time' => '25:00'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_time']);
    }

    public function test_manual_source_cannot_be_used_through_client_api(): void
    {
        $this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20'], $this->as)->assertCreated();
        $this->putJson('/api/events', ['events' => []], ['Authorization' => 'Bearer manual'])->assertStatus(401);
    }

    public function test_archive_lists_done_and_expired_newest_first(): void
    {
        $this->getJson('/me', $this->as);
        $user = User::sole();
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $make = fn (string $topic, EventState $state, string $updatedAt, ?string $doneLabel = null) => tap(Event::create([
            'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
            'due_mode' => DueMode::Local, 'due_date' => '2026-08-28', 'due_time' => '24:00:00', 'fire_on' => '2026-08-28',
            'after_due' => AfterDue::Expire, 'payload' => array_filter(['title' => "E $topic", 'done_label' => $doneLabel]),
            'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]), fn (Event $e) => Event::whereKey($e->id)->update(['updated_at' => $updatedAt]));
        $make('a', EventState::Done, '2026-09-01 10:00:00', 'Поздравил');
        $make('b', EventState::Expired, '2026-09-02 10:00:00');
        $make('c', EventState::Withdrawn, '2026-09-03 10:00:00');
        $make('d', EventState::Today, '2026-09-03 10:00:00');

        $r = $this->getJson('/me/archive', $this->as)->assertOk()->assertJsonCount(2, 'events');
        $this->assertSame(['b', 'a'], array_column($r->json('events'), 'topic'));
        $this->assertSame(['Истекло', 'Поздравил'], array_column($r->json('events'), 'closed_as'));
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter ManualEventsTest

Expected: FAIL (405/404).

  • Step 3: Implement

Add to core/app/Http/Controllers/Me/EventsController.php:

use App\Ingest\EventUpserter;
use App\Models\Source;
use Illuminate\Support\Str;

// constructor becomes:
public function __construct(private readonly EventActions $actions, private readonly EventUpserter $upserter) {}

public function store(Request $request): JsonResponse
{
    /** @var User $user */
    $user = $request->attributes->get('user');
    $data = $request->validate([
        'title' => ['required', 'string', 'max:200'],
        'subtitle' => ['sometimes', 'nullable', 'string', 'max:200'],
        'due_date' => ['required', 'date_format:Y-m-d'],
        'due_time' => ['sometimes', 'nullable', 'date_format:H:i'],
    ]);

    $source = Source::firstOrCreate(['name' => 'manual'], ['token_hash' => hash('sha256', Str::random(64))]);
    $dueAt = $data['due_date'].(empty($data['due_time']) ? '' : 'T'.$data['due_time']);

    [$result] = $this->upserter->upsert($source, [[
        'user' => $user->login,
        'source_ref' => 'manual:'.Str::ulid(),
        'topic' => '',
        'due_at' => $dueAt,
        'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
        'after_due' => 'keep',
        'payload' => array_filter([
            'title' => $data['title'],
            'subtitle' => $data['subtitle'] ?? null,
            'done_label' => 'Сделано',
        ], fn ($v) => $v !== null && $v !== ''),
    ]]);

    $event = Event::with('source')->findOrFail($result['id']);

    return response()->json(ApiPresenter::present($event) + ['source' => $event->source->name], 201);
}

public function archive(Request $request): JsonResponse
{
    /** @var User $user */
    $user = $request->attributes->get('user');
    $events = $user->events()->with('source')
        ->whereIn('state', [EventState::Done->value, EventState::Expired->value])
        ->orderByDesc('updated_at')->limit(100)->get();

    return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + [
        'source' => $e->source->name,
        'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
    ])->values()]);
}

Routes, inside the remote.user group in core/routes/web.php:

Route::post('/me/events', [MeEvents::class, 'store']);
Route::get('/me/archive', [MeEvents::class, 'archive']);
  • Step 4: Run tests
cd core && php artisan test --filter "ManualEventsTest|MeApiTest"

Expected: all PASS.

  • Step 5: Spec and functional doc

In docs/superpowers/specs/2026-09-03-hado-core-design.md §10 add after the route list: «POST /me/events — ручное событие: источник manual (зарезервирован, токена нет), fire_at = срок 30 дней, after_due = keep. GET /me/archive — закрытые (done, expired) за период retention, новые сверху, с подписью закрытия.» Update the inbox paragraph to mention the archive block and the add form. These were added by the design handoff on 2026-09-04.

  • Step 6: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: manual events and archive API

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 12: Web inbox page (по дизайну handoff)

The page follows docs/design/handoff/hado-inbox.dc.html (working prototype) and docs/design/handoff/README.md (tokens and rules). Reproduce it in Blade + vanilla JS; the values below are copied from the prototype, not invented. Dark only. Sekai design system: 60% dusk, 30% text neutrals, ~10% cold accent (--ruri/--byakugun) on action buttons, counter and time. --ruri is never used as text. Errors are --sakura.

Files:

  • Create: core/app/Http/Controllers/InboxController.php, core/resources/views/inbox.blade.php, core/public/inbox.js
  • Modify: core/routes/web.php (add GET / inside the remote.user group)
  • Delete: core/resources/views/welcome.blade.php
  • Test: core/tests/Feature/InboxPageTest.php

Interfaces:

  • Consumes: /me, /me/events (GET/POST), /me/events/{id}/ack|done, /me/archive, /me/channels*, /me/heartbeat.

  • Produces: GET / rendering the whole inbox server-side (groups, archive rows, settings); JS only toggles sections, submits forms and sends the heartbeat; every mutation reloads the page.

  • Step 1: Failing test

core/tests/Feature/InboxPageTest.php:

<?php

namespace Tests\Feature;

use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class InboxPageTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        config(['hado.default_tz' => 'UTC']);
        CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
    }

    protected function tearDown(): void
    {
        CarbonImmutable::setTestNow();
        parent::tearDown();
    }

    public function test_requires_sso(): void
    {
        $this->get('/')->assertStatus(401);
    }

    public function test_renders_groups_cards_archive_and_settings(): void
    {
        $this->get('/', ['X-Remote-User' => 'nikita']);
        $user = User::sole();
        $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
        $manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]);
        $make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([
            'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
            'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due,
            'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]),
            'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
        ]);
        $make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил');
        $make($source, 'b', EventState::Overdue, '2026-09-01');
        $make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null);
        $make($source, 'd', EventState::Scheduled, '2026-12-15');
        $make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил');
        $make($source, 'f', EventState::Expired, '2026-08-20');

        $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();

        // шапка и счётчик
        $this->assertStringContainsString('<title>(2) Hado</title>', $html);
        $this->assertStringContainsString('波動', $html);
        $this->assertStringContainsString('+ Добавить', $html);
        // группы в порядке и с кандзи
        $this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html);
        // карточки
        $this->assertStringContainsString('Событие a', $html);
        $this->assertStringContainsString('19:40', $html);
        $this->assertStringContainsString('СЕН', $html);
        $this->assertStringContainsString('сегодня', $html);
        $this->assertStringContainsString('3 дня назад', $html);
        $this->assertStringContainsString('через 26 дней', $html);
        $this->assertStringContainsString('вручную', $html);
        $this->assertStringContainsString('Поздравил', $html);
        $this->assertStringContainsString('Помню', $html);
        // архив
        $this->assertStringContainsString('2 закрыто', $html);
        $this->assertStringContainsString('Оплатил', $html);
        $this->assertStringContainsString('Истекло', $html);
        // настройки, форма добавления, присутствие
        $this->assertStringContainsString('nikita', $html);
        $this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
        $this->assertStringContainsString('/inbox.js', $html);
        $this->assertStringContainsString('csrf-token', $html);
    }

    public function test_counter_hidden_when_zero(): void
    {
        $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
        $this->assertStringContainsString('<title>Hado</title>', $html);
        $this->assertStringNotContainsString('class="counter"', $html);
    }
}
  • Step 2: Run to verify failure
cd core && php artisan test --filter InboxPageTest

Expected: FAIL.

  • Step 3: Controller

core/app/Http/Controllers/InboxController.php:

<?php

namespace App\Http\Controllers;

use App\Enums\EventState;
use App\Http\Controllers\Api\EventsController as Presenter;
use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\ProfileController;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;

class InboxController extends Controller
{
    private const MONTHS = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];

    public function __construct(private readonly DueResolver $due) {}

    public function __invoke(Request $request): View
    {
        /** @var User $user */
        $user = $request->attributes->get('user');
        $today = $user->now()->startOfDay();

        $live = $user->events()->with('source')
            ->whereIn('state', EventState::nonTerminalValues())
            ->get()
            ->map(fn (Event $e) => $this->card($e, $user, $today))
            ->sortBy('sort')->values();

        $groups = [];
        foreach (['today' => ['今日', 'СЕГОДНЯ'], 'overdue' => ['遅延', 'ПРОСРОЧЕНО'], 'preparing' => ['近日', 'ГРЯДЁТ'], 'scheduled' => ['予定', 'ЗАПЛАНИРОВАНО']] as $state => [$jp, $ru]) {
            $groups[] = ['state' => $state, 'jp' => $jp, 'ru' => $ru, 'events' => $live->where('state', $state)->values()->all()];
        }

        $archive = $user->events()->with('source')
            ->whereIn('state', [EventState::Done->value, EventState::Expired->value])
            ->orderByDesc('updated_at')->limit(100)->get()
            ->map(fn (Event $e) => [
                'title' => $e->payload['title'],
                'source' => self::sourceLabel($e->source->name),
                'due' => Presenter::present($e)['due_at'],
                'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
            ])->values()->all();

        $profile = ProfileController::present($user);

        return view('inbox', [
            'profile' => $profile,
            'title' => $profile['badge'] > 0 ? "({$profile['badge']}) Hado" : 'Hado',
            'groups' => $groups,
            'archive' => $archive,
            'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(),
            'timezones' => \DateTimeZone::listIdentifiers(),
        ]);
    }

    /** @return array<string,mixed> */
    private function card(Event $e, User $user, CarbonImmutable $today): array
    {
        $due = $this->due->resolve($e, $user);
        $wholeDay = $due->moment->eq($due->day->addDay());
        $diff = (int) round($today->diffInDays($due->day));

        return [
            'id' => $e->id,
            'state' => $e->state->value,
            'title' => $e->payload['title'],
            'subtitle' => $e->payload['subtitle'] ?? null,
            'link' => $e->payload['deep_link'] ?? null,
            'done_label' => $e->payload['done_label'] ?? 'Сделано',
            'can_remind' => $e->state !== EventState::Scheduled,
            'source' => self::sourceLabel($e->source->name),
            'due' => Presenter::present($e)['due_at'],
            'due_month' => self::MONTHS[(int) $due->day->format('n') - 1],
            'due_day' => $due->day->format('d'),
            'due_time' => $wholeDay ? null : $due->moment->format('H:i'),
            'rel' => self::relative($diff),
            'sort' => $due->moment->getTimestamp(),
        ];
    }

    public static function sourceLabel(string $name): string
    {
        return $name === 'manual' ? 'вручную' : $name;
    }

    public static function relative(int $diff): string
    {
        $days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней');

        return match (true) {
            $diff === 0 => 'сегодня',
            $diff === 1 => 'завтра',
            $diff > 1 => "через {$diff} ".$days($diff),
            $diff === -1 => 'вчера',
            default => abs($diff).' '.$days(abs($diff)).' назад',
        };
    }
}
  • Step 4: Blade template

core/resources/views/inbox.blade.php — structure and values mirror the prototype; sister planets, nebula and star field included as in the Sekai template:

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,.16);--hair-2:rgba(174,180,214,.30)}
*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg)}
body{min-height:100vh;color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow-x:hidden;display:flex;flex-direction:column}
::selection{background:rgba(143,214,220,.25)}
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
input,select,button{font:inherit}button[disabled]{opacity:.45;pointer-events:none}
.mono{font-family:'Space Mono',monospace}.serif{font-family:'Shippori Mincho',serif}
.layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
.nebula{position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%,rgba(232,78,138,.14),transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%,rgba(159,143,203,.17),transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%,rgba(143,214,220,.11),transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%,rgba(53,92,214,.11),transparent 72%)}
.core{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%,rgba(247,206,220,.22),transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%,rgba(242,236,224,.18),transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%,rgba(234,143,174,.22),transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%,rgba(247,206,220,.24),transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%,rgba(242,236,224,.17),transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%,rgba(247,206,220,.18),transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%,rgba(143,214,220,.12),transparent 70%)}
.dust{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%,rgba(7,9,18,.6),transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%,rgba(9,11,22,.55),transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%,rgba(7,9,18,.55),transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%,rgba(9,11,22,.5),transparent 75%)}
.band{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
.sister{position:absolute}.sister .col{display:flex;flex-direction:column;align-items:center;gap:9px}
.skdot{pointer-events:auto;border-radius:50%}.sklbl{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s}.skdot:hover+.sklbl{opacity:1}
header{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0}
.brand{display:flex;align-items:baseline;gap:14px}.brand .jp{font-family:'Shippori Mincho',serif;font-size:54px;line-height:1}.brand .lat{font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)}
.counter{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)}
.counter .dot{width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,var(--ruri),#24337F 55%,var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite}
.counter .n{font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)}
.tools{display:flex;align-items:center;gap:16px;margin-top:14px}.num{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
.btn{border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s,border-color .2s,color .2s}
.primary{background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun)}.primary:hover{background:rgba(53,92,214,.4)}
.ghost{background:transparent;border:1px solid var(--hair-2);color:var(--kasumi)}.ghost:hover{border-color:var(--byakugun);color:var(--byakugun)}
.ghost.danger:hover{border-color:var(--nadeshiko);color:var(--sakura)}
main{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px;flex:1}
.panel{border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden;margin-top:64px}
.panel>button.head{width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)}
.panel .head .jp{font-family:'Shippori Mincho',serif;font-size:16px}.panel .head .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)}.panel .head .chev{margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)}
.panel .body{border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px}
.addform{margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px}
.label{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px}
.row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end}
.field{display:flex;flex-direction:column;gap:6px}.field span{font-size:12px;color:var(--nibi)}
.field input,.field select{background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px}
.err{color:var(--sakura);font-size:12px;margin-top:12px}.ok{color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px}
.group{margin-bottom:52px}.group .h{display:flex;align-items:baseline;gap:12px;margin-bottom:18px}.group .h .jp{font-family:'Shippori Mincho',serif;font-size:20px}.group .h .ru{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)}.group .h .line{flex:1;border-bottom:1px solid var(--hair)}
.empty{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px}
.cards{display:flex;flex-direction:column;gap:12px}
.card{background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.plaque{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)}
.plaque .m{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)}.plaque .d{font-family:'Shippori Mincho',serif;font-size:24px;line-height:1}.plaque .t{font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)}
.card .text{flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px}
.card .title{font-size:16px;font-weight:500;color:var(--washi);align-self:flex-start}.card a.title{text-decoration:underline dotted var(--hair-2);text-underline-offset:4px}.card a.title:hover{color:var(--byakugun)}
.card .sub{font-size:13px;color:var(--kasumi)}.card .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px}.card .meta .rel{color:var(--kasumi)}
.card .actions{display:flex;gap:10px;align-items:center}
.arow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75}
.arow .t{font-size:14px;color:var(--kasumi);flex:1;min-width:180px}.arow .m{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)}.arow .tag{font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px}
.chrow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)}.chrow .n{font-size:14px;min-width:76px}.chrow .note{font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere}
.tgbox{border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px}
footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
</style>
</head>
<body>
<div class="layer" id="stars"></div>
<div class="nebula"></div><div class="core"></div><div class="dust"></div>
<div class="band" id="band"></div>

{{-- сёстры-планеты: все, кроме Hado --}}
<div class="layer">
  <div class="sister" style="top:17%;left:7%"><div style="animation:skOrbXa 19s ease-in-out infinite"><div class="col" style="animation:skOrbYa 19s ease-in-out -4.75s infinite"><div class="skdot" style="width:15px;height:15px;background:radial-gradient(circle at 32% 28%,var(--sakura),var(--nadeshiko) 50%,var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div><span class="sklbl">YUME</span></div></div></div>
  <div class="sister" style="top:26%;right:8%"><div style="animation:skOrbXb 27s ease-in-out infinite"><div class="col" style="animation:skOrbYb 27s ease-in-out -20.25s infinite"><div class="skdot" style="width:12px;height:12px;background:radial-gradient(circle at 32% 28%,var(--tsutsuji),var(--kobai) 52%,var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div><span class="sklbl">MYAKU</span></div></div></div>
  <div class="sister" style="top:60%;left:4.5%"><div style="animation:skOrbXc 14s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite"><div class="skdot" style="width:9px;height:9px;background:radial-gradient(circle at 34% 30%,var(--fuji),#4A4177 55%,var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div><span class="sklbl">KIOKU</span></div></div></div>
  <div class="sister" style="top:64%;right:6%"><div style="animation:skOrbXd 22s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite"><div class="skdot" style="width:10px;height:10px;background:radial-gradient(circle at 32% 28%,var(--byakugun),var(--hanada) 50%,var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div><span class="sklbl">FUMI</span></div></div></div>
</div>

<header>
  <div class="brand">
    <span class="jp">波動</span><span class="lat">HADO</span>
    @if($profile['badge'] > 0)<span class="counter"><span class="dot"></span><span class="n">{{ $profile['badge'] }}</span></span>@endif
  </div>
  <div class="tools">
    <button class="btn primary" id="toggle-add">+ Добавить</button>
    <span class="num">05</span>
  </div>
</header>

<main>
  <section class="addform" id="addform" hidden>
    <div class="label">НОВОЕ СОБЫТИЕ</div>
    <form class="row" id="add">
      <label class="field" style="flex:2;min-width:220px"><span>Заголовок</span><input name="title" placeholder="Продлить страховку" required maxlength="200"></label>
      <label class="field" style="flex:1;min-width:160px"><span>Подзаголовок (опц.)</span><input name="subtitle" placeholder="раз в год" maxlength="200"></label>
      <label class="field"><span>Срок</span><input name="due_date" type="date" required></label>
      <label class="field"><span>Время (опц.)</span><input name="due_time" type="time"></label>
      <button type="submit" class="btn primary">Создать</button>
      <button type="button" class="btn ghost" id="cancel-add">Отмена</button>
    </form>
    <div class="err" id="add-err" hidden></div>
  </section>

  @foreach($groups as $group)
    <section class="group">
      <div class="h"><span class="jp">{{ $group['jp'] }}</span><span class="ru">{{ $group['ru'] }}</span><span class="line"></span></div>
      @if(empty($group['events']))<div class="empty">ПУСТО</div>@endif
      <div class="cards">
        @foreach($group['events'] as $ev)
          <article class="card" data-id="{{ $ev['id'] }}">
            <div class="plaque"><span class="m">{{ $ev['due_month'] }}</span><span class="d">{{ $ev['due_day'] }}</span>@if($ev['due_time'])<span class="t">{{ $ev['due_time'] }}</span>@endif</div>
            <div class="text">
              @if($ev['link'])<a class="title" href="{{ $ev['link'] }}">{{ $ev['title'] }}</a>@else<span class="title">{{ $ev['title'] }}</span>@endif
              @if($ev['subtitle'])<span class="sub">{{ $ev['subtitle'] }}</span>@endif
              <span class="meta">{{ $ev['source'] }} · срок {{ $ev['due'] }} · <span class="rel">{{ $ev['rel'] }}</span></span>
            </div>
            <div class="actions">
              @if($ev['can_remind'])<button class="btn ghost" data-action="ack">Помню</button>@endif
              <button class="btn primary" data-action="done">{{ $ev['done_label'] }}</button>
            </div>
          </article>
        @endforeach
      </div>
    </section>
  @endforeach

  <section class="panel" data-panel="archive">
    <button class="head" type="button"><span class="jp">書庫</span><span>Архив</span><span class="meta">{{ count($archive) }} закрыто</span><span class="chev">▼</span></button>
    <div class="body" hidden>
      @if(empty($archive))<div class="empty">ПУСТО</div>@endif
      @foreach($archive as $ar)
        <div class="arow"><span class="t">{{ $ar['title'] }}</span><span class="m">{{ $ar['source'] }} · {{ $ar['due'] }}</span><span class="tag">{{ $ar['closed_as'] }}</span></div>
      @endforeach
    </div>
  </section>

  <section class="panel" data-panel="settings">
    <button class="head" type="button"><span class="jp">設定</span><span>Настройки</span><span class="meta">{{ $profile['login'] }}</span><span class="chev">▼</span></button>
    <div class="body" hidden style="padding:26px 22px 30px;gap:34px">
      <div>
        <div class="label">ПОЯС И ТИХИЕ ЧАСЫ</div>
        <form class="row" id="profile">
          <label class="field" style="min-width:240px"><span>Часовой пояс</span>
            <select name="tz">@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach</select>
          </label>
          <label class="field"><span>Тихие часы, с</span><input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
          <label class="field"><span>до</span><input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
          <button type="submit" class="btn primary">Сохранить</button>
        </form>
        <div class="err" id="profile-err" hidden></div>
      </div>

      <div>
        <div class="label">КАНАЛЫ</div>
        <div style="display:flex;flex-direction:column;gap:10px">
          @foreach($channels as $ch)
            <div class="chrow">
              <span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
              <span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span>
              @if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
            </div>
          @endforeach
        </div>
      </div>

      <div>
        <div class="label">ДОБАВИТЬ WEBHOOK</div>
        <form class="row" id="webhook">
          <label class="field" style="flex:1;min-width:220px"><span>Адрес доставки</span><input name="deliver_url" type="url" placeholder="https://home.local/hado/notify" required></label>
          <label class="field" style="flex:1;min-width:220px"><span>Адрес присутствия</span><input name="presence_url" type="url" placeholder="https://home.local/presence" required></label>
          <button type="submit" class="btn ghost">Добавить webhook</button>
        </form>
        <div class="err" id="webhook-err" hidden></div>
      </div>

      <div>
        <div class="label">TELEGRAM</div>
        <button class="btn ghost" id="tg-connect">Подключить Telegram</button>
        <div class="tgbox" id="tg-box" hidden>
          <span style="font-size:14px">Отправь боту <span class="mono" style="color:var(--byakugun)" id="tg-code"></span> или <a id="tg-link" href="#" target="_blank" rel="noopener">открой</a></span>
          <span class="mono" style="font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
        </div>
      </div>
    </div>
  </section>
</main>

<footer><span>波動 HADO</span><span>· 世界 SEKAI</span></footer>
<script src="/inbox.js"></script>
</body>
</html>
  • Step 5: Page script

core/public/inbox.js:

(() => {
  const csrf = document.querySelector('meta[name=csrf-token]').content;
  const call = (method, url, body) => fetch(url, {
    method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: body ? JSON.stringify(body) : undefined,
  });
  const showErr = async (el, r) => {
    const d = await r.json().catch(() => ({}));
    el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
    el.hidden = false;
  };

  // Звёзды: 90 по небу, 170 в полосе (дуга 14°)
  const star = (color, size, bright, x, y, dur, delay) => {
    const s = document.createElement('div');
    s.style.cssText = `position:absolute;border-radius:50%;left:${x}%;top:${y}%;width:${size}px;height:${size}px;background:${color};${bright ? `box-shadow:0 0 6px ${color};` : ''}animation:skTwinkle ${dur}s ease-in-out ${delay}s infinite`;
    return s;
  };
  const sky = document.getElementById('stars'), band = document.getElementById('band');
  for (let i = 0; i < 90; i++) sky.appendChild(star(Math.random() < .2 ? '#8FD6DC' : '#F2ECE0', Math.random() < .85 ? 1.5 : 2.5, false, (Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
  for (let j = 0; j < 170; j++) {
    const bright = Math.random() < .08, r = Math.random();
    const color = r < .68 ? '#F2ECE0' : (r < .88 ? '#F7CEDC' : '#8FD6DC');
    const g = (Math.random() + Math.random() + Math.random()) / 3, x = Math.random() * 100;
    const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * .35;
    band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright, x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
  }

  // Панели архив/настройки
  document.querySelectorAll('.panel .head').forEach(h => h.addEventListener('click', () => {
    const body = h.nextElementSibling; body.hidden = !body.hidden;
    h.querySelector('.chev').textContent = body.hidden ? '▼' : '▲';
  }));

  // Форма добавления
  const addform = document.getElementById('addform');
  const toggleAdd = () => { addform.hidden = !addform.hidden; document.getElementById('add-err').hidden = true; };
  document.getElementById('toggle-add').addEventListener('click', toggleAdd);
  document.getElementById('cancel-add').addEventListener('click', toggleAdd);
  document.getElementById('add').addEventListener('submit', async e => {
    e.preventDefault();
    const f = new FormData(e.target);
    const r = await call('POST', '/me/events', { title: f.get('title'), subtitle: f.get('subtitle') || null, due_date: f.get('due_date'), due_time: f.get('due_time') || null });
    if (r.ok) location.reload(); else showErr(document.getElementById('add-err'), r);
  });

  // «Помню» / «Сделано»
  document.querySelectorAll('.card button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
    const card = btn.closest('.card');
    card.querySelectorAll('button').forEach(b => b.disabled = true);
    const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
    if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false);
  }));

  // Настройки
  document.getElementById('profile').addEventListener('submit', async e => {
    e.preventDefault();
    const f = new FormData(e.target);
    const r = await call('PATCH', '/me', { tz: f.get('tz'), quiet_start: f.get('quiet_start'), quiet_end: f.get('quiet_end') });
    if (r.ok) location.reload(); else showErr(document.getElementById('profile-err'), r);
  });
  document.getElementById('webhook').addEventListener('submit', async e => {
    e.preventDefault();
    const f = new FormData(e.target);
    const r = await call('POST', '/me/channels', { type: 'webhook', config: { deliver_url: f.get('deliver_url'), presence_url: f.get('presence_url') } });
    if (r.ok) location.reload(); else showErr(document.getElementById('webhook-err'), r);
  });
  document.querySelectorAll('[data-delete-channel]').forEach(btn => btn.addEventListener('click', async () => {
    const r = await call('DELETE', `/me/channels/${btn.dataset.deleteChannel}`);
    if (r.ok) location.reload();
  }));
  document.getElementById('tg-connect').addEventListener('click', async e => {
    const r = await call('POST', '/me/channels/telegram/link');
    if (!r.ok) return;
    const d = await r.json();
    document.getElementById('tg-code').textContent = `/start ${d.code}`;
    document.getElementById('tg-link').href = d.bot_url;
    e.target.hidden = true; document.getElementById('tg-box').hidden = false;
  });

  // Присутствие: heartbeat только при видимой вкладке и активности за 3 минуты.
  let lastActivity = Date.now();
  ['pointerdown', 'pointermove', 'keydown', 'scroll'].forEach(ev => addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true }));
  const heartbeat = () => {
    if (document.visibilityState !== 'visible') return;
    if (Date.now() - lastActivity > 3 * 60 * 1000) return;
    call('POST', '/me/heartbeat').catch(() => {});
  };
  heartbeat();
  setInterval(heartbeat, 30 * 1000);
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { lastActivity = Date.now(); heartbeat(); } });
})();

Add to the remote.user group in core/routes/web.php:

Route::get('/', \App\Http\Controllers\InboxController::class);

Delete core/resources/views/welcome.blade.php.

  • Step 6: Run tests, Pint, whole suite
cd core && php artisan test --filter InboxPageTest && php artisan test && vendor/bin/pint --test

Expected: all green. In test_renders_groups_cards_archive_and_settings the relative strings depend on setTestNow (2026-09-04): «сегодня» for 09-04, «3 дня назад» for 09-01, «через 26 дней» for 09-30.

  • Step 7: Visual check against the prototype

Open docs/design/handoff/hado-inbox.dc.html in a browser (keep support.js next to it) and http://localhost:8080/ with an X-Remote-User header (Task 13 compose). Compare: header, counter pill, group headings, card plaque, buttons, archive rows, settings. Fix spacing or token mismatches in inbox.blade.php only; do not change behavior.

  • Step 8: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox per Sekai design handoff

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Task 13: Docker Compose, Dockerfiles, Caddy snippet, README

Files:

  • Create: docker-compose.yml, core/Dockerfile, core/Dockerfile.dev, deploy/Caddyfile.snippet, README.md, .env.example (root, for compose)

Interfaces:

  • Produces: docker compose up -d --build runs app (:8080), scheduler, worker, postgres; docker compose exec app php artisan migrate and hado:source:create work.

  • Step 1: Files

core/Dockerfile.dev:

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):

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:

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:

# Hado: публичные пути (клиенты по токену, кнопки из каналов, Telegram) идут мимо SSO,
# всё остальное — через forward_auth хаба. Порядок матчеров важен.
hado.{$DOMAIN} {
	@public path /api/* /a/* /hooks/* /up
	handle @public {
		reverse_proxy <hado-wireguard-ip>:8080
	}
	handle {
		import protected
		reverse_proxy <hado-wireguard-ip>:8080
	}
}

README.md:

# Hado — 波 · ядро умных уведомлений

Сервисы пушат события с датой, ядро само считает каскад напоминаний, окна и
доставляет туда, где ты сейчас доступен (веб, Telegram, webhook/Home Assistant).
Спека: `docs/superpowers/specs/2026-09-03-hado-core-design.md`.

## Dev

```bash
cp core/.env.example core/.env
docker compose up -d --build
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate
docker compose exec app php artisan hado:source:create docs   # токен печатается один раз
```

- http://localhost:8080/ — инбокс (нужен заголовок `X-Remote-User`; локально: расширение
  браузера для заголовков или `curl -H 'X-Remote-User: nikita' localhost:8080/me`)
- `PUT /api/events` с `Authorization: Bearer <токен>` — см. спеку §5

Тесты локально: `cd core && php artisan test` (SQLite in-memory, нужны расширения
`pdo_sqlite`, `intl` в php.ini). Стиль: `vendor/bin/pint`.

## Telegram

1. Создай бота у @BotFather, положи токен и username в `core/.env`.
2. Придумай `TELEGRAM_WEBHOOK_SECRET`, зарегистрируй webhook:
   `curl "https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://hado.<домен>/hooks/telegram&secret_token=<SECRET>"`
3. В инбоксе «Подключить Telegram» → отправь боту `/start <код>`.

## Home Assistant

В инбоксе добавь webhook-канал: `deliver_url` — HA webhook-триггер
(`https://ha/api/webhook/<id>`), `presence_url` — любой URL, отвечающий
`{"present": true|false}` (например, template-сенсор через REST API или Node-RED).
Автоматизация в HA получает JSON `{event, actions}` и может дёрнуть `actions.done`
POST-ом без авторизации.

## Прод

`core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy —
`deploy/Caddyfile.snippet` в хаб sekai.
  • Step 2: Smoke test in Docker
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:

TOKEN=<paste>
curl -s -X PUT localhost:8080/api/events -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"events":[{"user":"nikita","source_ref":"person:42","topic":"birthday:2026","due_at":"2026-09-04","fire_at":"2026-09-04","after_due":"expire","payload":{"title":"Тест"}}]}'
curl -s localhost:8080/me -H 'X-Remote-User: nikita'
docker compose logs scheduler --tail 5

Expected: JSON with "state":"today" (if run during the day in the default tz), /me shows "badge":1, scheduler log shows hado:tick running every minute.

  • Step 3: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "chore: docker compose, dockerfiles, caddy snippet, readme

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"

Spec coverage check

Spec section Task
§4 model, UNIQUEs, meta deviation 2
§5 due_at forms, validation, upsert rules, recurring rows, retention, DELETE/GET 5, 6, 9
§6 states, transitions, eve rule, recompute on upsert 3, 4, 5, 9
§7 cascade 3
§8 windows, truncated window fallback, quiet rule, ack/done paths 4, 7, 10
§9 drivers, presence table, web heartbeat rule, tick algorithm, pending row, retries 8, 9, 12
§10 /me API, inbox, tz/quiet hours, channels, telegram link, manual events, archive 7, 10, 11, 12
§11 three auth mechanisms, Caddy bypass paths 6, 7, 10, 13
§12 compose, scheduler, worker, env 13
§13 tests every task

Known simplification vs spec: presence probes run sequentially per user (each cached 60 s) instead of in parallel; only webhook does network I/O, so the difference is at most one 2-second timeout per user per minute. Note it in the spec §9 if it ever matters.