Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
186 KiB
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/. Onlytitle,subtitle,deep_link,done_labelin payload. - Event identity is
(source_id, source_ref, topic), UNIQUE. Recurring events use a period intopic(birthday:2026); the core never infers recurrence. due_modeis an explicit enumlocal | fixed; code branches on it, never on a null column.fire_onis always set; no preparation meansfire_on = due day.after_dueenumkeep | expire.- States:
scheduled, preparing, today, overdue, done, expired, withdrawn. Badge = count oftoday+overdue. - Active hours per user:
[quiet_end, quiet_start), defaults09: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=10minutes; today fallback only in hoursHADO_FALLBACK_HOURS=9,14,20or in a window truncated by the due moment. - One delivery row per
(event_id, channel_id, window_start), inserted aspendingbefore the job is dispatched. - Presence cache TTL 60 s; presence HTTP timeout
HADO_PRESENCE_TIMEOUT=2s. - Terminal events purged after
HADO_RETENTION_DAYS=90. - All user-facing strings in Russian: «Помню», «Сделано», «Подключено».
- Tests: PHPUnit,
RefreshDatabase, time viaCarbonImmutable::setTestNow, HTTP viaHttp::fake(). Run withcd 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
stringcolumns backed by PHP enums (portable to SQLite for tests);deliveriesgains ametajson column for channel bookkeeping (Telegrammessage_id). Task 2 patches the spec accordingly.
File Structure
core/ Laravel app (created in Task 1)
app/Enums/DueMode.php local | fixed
app/Enums/AfterDue.php keep | expire
app/Enums/EventState.php 7 states + isTerminal/isActive/countsInBadge
app/Enums/ChannelType.php web | telegram | webhook
app/Enums/DeliveryResult.php pending | ok | failed
app/Enums/Presence.php present | absent | unknown
app/Models/{Source,User,Channel,Event,Delivery}.php
app/Scheduling/Due.php value: day + moment (local)
app/Scheduling/DueResolver.php Event+User → Due, todayStart, fireStart
app/Scheduling/Cascade.php halving points
app/Scheduling/StateResolver.php dates + now → EventState
app/Scheduling/Window.php value: start, end, fallbackAllowed, fallbackAt
app/Scheduling/WindowResolver.php state + now → ?Window
app/Scheduling/QuietRule.php «Помню» → quiet_until
app/Ingest/DueAtParser.php "2026-08-14" | "…T14:00" | "…T08:15+02:00" → ParsedDue
app/Ingest/ParsedDue.php value object
app/Ingest/EventUpserter.php upsert rules from spec §5
app/Channels/ChannelDriver.php interface: presence, deliver, onDone
app/Channels/DeliveryOutcome.php ok | failed(error)
app/Channels/ChannelRegistry.php ChannelType → driver
app/Channels/WebChannel.php
app/Channels/WebhookChannel.php
app/Channels/TelegramChannel.php
app/Delivery/Tick.php per-minute planner
app/Delivery/DeliverJob.php queued delivery with retries
app/Delivery/EventActions.php ack / done
app/Http/Middleware/AuthenticateSource.php
app/Http/Middleware/RemoteUser.php
app/Http/Requests/UpsertEventsRequest.php
app/Http/Controllers/Api/EventsController.php
app/Http/Controllers/Me/{ProfileController,EventsController,ChannelsController}.php
app/Http/Controllers/ActionController.php
app/Http/Controllers/TelegramWebhookController.php
app/Http/Controllers/InboxController.php
app/Console/Commands/{SourceCreate,TickCommand,PurgeCommand}.php
config/hado.php
database/migrations/0001_01_01_000000_create_hado_tables.php
resources/views/inbox.blade.php
routes/{api,web,console}.php
tests/Unit/Scheduling/*, tests/Unit/Ingest/*, tests/Feature/*
docker-compose.yml, core/Dockerfile, core/Dockerfile.dev, deploy/Caddyfile.snippet, README.md
Task 1: Scaffold the Laravel app and local toolchain
Files:
- Create:
core/(Laravel 13 skeleton via composer) - Modify:
core/phpunit.xml,core/.env.example,C:\php-8.5.4\php.ini(enable extensions) - Create:
.gitignore(root)
Interfaces:
-
Produces: a runnable Laravel app where
php artisan testpasses 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.examplefor 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
.gitignoreand 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();Eventcasts (due_date/fire_on→CarbonImmutabledate,due_instant/quiet_until/done_at→CarbonImmutable,payload→ array, enums);Event::isTerminal();Deliverywithmetaarray. -
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,Usermodels (in-memory, no DB). -
Produces:
final class Due { CarbonImmutable $day; CarbonImmutable $moment; }both in the user's tz;dayis local midnight of the due day;momentis the due moment (for a whole-day event: next midnight).DueResolver::resolve(Event, User): DueDueResolver::fireStart(Event, User): CarbonImmutablelocal midnight offire_onDueResolver::todayStart(Due, User): CarbonImmutablelocal midnight of due day, orquiet_start − 2hthe day before whenmoment <= 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(bindWindowResolverfrom 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 returnsDone/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): ?WindowQuietRule::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, confighado.default_tz. -
Produces:
final class ParsedDue { DueMode $mode; ?string $date; ?string $time; ?CarbonImmutable $instant; }withstatic local(string $date, string $time),static fixed(CarbonImmutable),dayFor(string $tz): string(Y-m-d),equals(Event): bool.DueAtParser::parse(string): ParsedDuethrowsInvalidArgumentExceptionon bad input.EventUpserter::upsert(Source $source, array $items): list<array{source_ref,topic,id,state}>—$itemsare 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(registerapiroutes, 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 attributesource(Sourcemodel) set by middleware aliasauth.source; artisanhado:source:create {name}printingToken: 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; foronDone— until Task 8 exists,EventActions::doneonly changes state; Task 8 adds the hook). -
Produces:
- middleware alias
remote.user: readsX-Remote-User, 401 if missing,ensureUser, sets request attributeuser. 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}, TTLhado.web_heartbeat_ttl.
- middleware alias
-
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(callonDonefor 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 callroute()so Task 10 must name them exactlyaction.ackandaction.done. In unit tests of this task, register the two routes insetUp(see tests). TelegramChannel::touch(int|string $chatId): voidmarks the chat as recently seen (cachetg:seen:{chat}, TTLhado.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} }.
- Action URLs:
-
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]; marksok/failed.- artisan
hado:tick(every minute),hado:purge(daily at 04:00). - Presence cache key
presence:{channel_id}, TTLhado.presence_ttl. - Fake driver for tests:
Tests\Support\FakeChannel(incore/tests/Support/FakeChannel.php), bound overChannelRegistryvia aTests\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"
Task 10: Action links, Telegram webhook, channels API
Files:
- Create:
core/app/Http/Controllers/ActionController.php,core/app/Http/Controllers/TelegramWebhookController.php,core/app/Http/Controllers/Me/ChannelsController.php - Modify:
core/routes/web.php - Test:
core/tests/Feature/ActionLinksTest.php,core/tests/Feature/TelegramWebhookTest.php,core/tests/Feature/ChannelsApiTest.php
Interfaces:
-
Consumes:
EventActions,TelegramChannel::touch/sendText/answerCallback,Delivery.action_token. -
Produces:
POST /a/{token}/ack→action.ack,POST /a/{token}/done→action.done. 404 unknown token, 410 if the event is terminal, 200{state}otherwise. No CSRF, no SSO.POST /hooks/telegram— checks headerX-Telegram-Bot-Api-Secret-Tokenagainsthado.telegram.webhook_secret(403 otherwise). Handles/start <code>linking andcallback_querywithack:{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 keytg: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: Web inbox page
Files:
- Create:
core/app/Http/Controllers/InboxController.php,core/resources/views/inbox.blade.php - Modify:
core/routes/web.php(addGET /inside theremote.usergroup) - Delete:
core/resources/views/welcome.blade.php - Test:
core/tests/Feature/InboxPageTest.php
Interfaces:
-
Consumes:
/me/*JSON endpoints (the page is plain HTML + vanilla JS calling them withX-CSRF-TOKEN). -
Produces:
GET /renders groupstoday,overdue,preparing,scheduled; buttons «Помню» / done_label; settings block (tz, quiet hours, channels, Telegram link). Heartbeat every 30 s only whendocument.visibilityState === 'visible'and pointer/keyboard activity within 3 minutes. -
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;
public function test_requires_sso(): void
{
$this->get('/')->assertStatus(401);
}
public function test_renders_groups_and_buttons(): void
{
$this->get('/', ['X-Remote-User' => 'nikita']);
$user = User::sole();
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
$make = fn (string $topic, EventState $state, ?string $doneLabel = null) => Event::create([
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-08-31',
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'deep_link' => '/x', 'done_label' => $doneLabel]),
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]);
$make('a', EventState::Today, 'Поздравил');
$make('b', EventState::Overdue);
$make('c', EventState::Preparing);
$make('d', EventState::Scheduled);
$make('e', EventState::Done);
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
$this->assertStringContainsString('Событие a', $html);
$this->assertStringContainsString('Событие d', $html);
$this->assertStringNotContainsString('Событие e', $html);
$this->assertStringContainsString('Поздравил', $html);
$this->assertStringContainsString('Помню', $html);
$this->assertStringContainsString('Сделано', $html);
$this->assertStringContainsString('/me/heartbeat', $html);
$this->assertStringContainsString('visibilityState', $html);
$this->assertStringContainsString('csrf-token', $html);
}
}
- Step 2: Run to verify failure
cd core && php artisan test --filter InboxPageTest
Expected: FAIL (/ renders the Laravel welcome page or 404).
- Step 3: Implement
core/app/Http/Controllers/InboxController.php:
<?php
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 Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class InboxController extends Controller
{
public function __invoke(Request $request): View
{
/** @var User $user */
$user = $request->attributes->get('user');
$events = $user->events()->with('source')
->whereIn('state', EventState::nonTerminalValues())
->orderBy('due_date')->orderBy('due_instant')->get()
->map(fn (Event $e) => Presenter::present($e) + ['source' => $e->source->name]);
$groups = [];
foreach (['today' => 'Сегодня', 'overdue' => 'Просрочено', 'preparing' => 'Грядёт', 'scheduled' => 'Запланировано'] as $state => $label) {
$groups[] = ['state' => $state, 'label' => $label, 'events' => $events->where('state', $state)->values()->all()];
}
return view('inbox', [
'profile' => ProfileController::present($user),
'groups' => $groups,
'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(),
'timezones' => \DateTimeZone::listIdentifiers(),
]);
}
}
core/resources/views/inbox.blade.php:
<!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>Hado · {{ $profile['badge'] > 0 ? '('.$profile['badge'].') ' : '' }}уведомления</title>
<style>
body { font: 15px/1.4 system-ui, sans-serif; margin: 0; background: #f6f6f4; color: #1a1a1a; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 20px; display: flex; align-items: center; gap: 10px; }
.badge { background: #d33; color: #fff; border-radius: 12px; padding: 0 8px; font-size: 13px; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: #666; margin: 28px 0 8px; }
.event { background: #fff; border-radius: 10px; padding: 12px 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center; }
.event .text { flex: 1; min-width: 0; }
.event .title { font-weight: 600; }
.event .sub { color: #666; font-size: 13px; }
.event .meta { color: #999; font-size: 12px; }
button { border: 0; border-radius: 8px; padding: 8px 12px; cursor: pointer; font: inherit; }
.ack { background: #eee; }
.done { background: #1a1a1a; color: #fff; }
.empty { color: #999; padding: 8px 0; }
details { background: #fff; border-radius: 10px; padding: 12px 14px; margin-top: 36px; }
summary { cursor: pointer; font-weight: 600; }
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 10px 0; }
input, select { font: inherit; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; }
ul.channels { list-style: none; padding: 0; }
ul.channels li { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-top: 1px solid #eee; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<main>
<h1>Hado @if($profile['badge'] > 0)<span class="badge">{{ $profile['badge'] }}</span>@endif</h1>
@foreach($groups as $group)
<h2>{{ $group['label'] }}</h2>
@forelse($group['events'] as $event)
<div class="event" data-id="{{ $event['id'] }}">
<div class="text">
<div class="title">
@if(!empty($event['payload']['deep_link']))<a href="{{ $event['payload']['deep_link'] }}">{{ $event['payload']['title'] }}</a>@else{{ $event['payload']['title'] }}@endif
</div>
@if(!empty($event['payload']['subtitle']))<div class="sub">{{ $event['payload']['subtitle'] }}</div>@endif
<div class="meta">{{ $event['source'] }} · срок {{ $event['due_at'] }}</div>
</div>
@if($group['state'] !== 'scheduled')
<button class="ack" data-action="ack">Помню</button>
@endif
<button class="done" data-action="done">{{ $event['payload']['done_label'] ?? 'Сделано' }}</button>
</div>
@empty
<div class="empty">Пусто</div>
@endforelse
@endforeach
<details>
<summary>Настройки · {{ $profile['login'] }}</summary>
<form class="row" id="profile">
<label>Пояс
<select name="tz">
@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach
</select>
</label>
<label>Тихие часы с <input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
<label>до <input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
<button type="submit" class="done">Сохранить</button>
</form>
<h2>Каналы</h2>
<ul class="channels">
@foreach($channels as $channel)
<li>
<strong>{{ $channel['type'] }}</strong>
<span class="meta">{{ $channel['type'] === 'webhook' ? $channel['config']['deliver_url'] : ($channel['type'] === 'telegram' ? 'chat '.$channel['config']['chat_id'] : 'этот инбокс') }}</span>
@if($channel['type'] !== 'web')<button class="ack" data-delete-channel="{{ $channel['id'] }}">Удалить</button>@endif
</li>
@endforeach
</ul>
<form class="row" id="webhook">
<input name="deliver_url" placeholder="Webhook: deliver_url" size="34" required>
<input name="presence_url" placeholder="presence_url" size="34" required>
<button type="submit" class="done">Добавить webhook</button>
</form>
<form class="row" id="telegram">
<button type="submit" class="done">Подключить Telegram</button>
<span id="tg-result"></span>
</form>
</details>
</main>
<script>
(() => {
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,
});
// Кнопки «Помню» / «Сделано»
document.querySelectorAll('.event button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('.event');
btn.disabled = true;
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
if (r.ok) location.reload(); else btn.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 alert((await r.json()).message || 'Ошибка');
});
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 alert((await r.json()).message || 'Ошибка');
});
document.getElementById('telegram').addEventListener('submit', async e => {
e.preventDefault();
const r = await call('POST', '/me/channels/telegram/link');
const d = await r.json();
document.getElementById('tg-result').innerHTML = `Отправь боту <code>/start ${d.code}</code> или <a href="${d.bot_url}" target="_blank">открой</a>`;
});
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();
}));
// Присутствие: 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(); } });
})();
</script>
</body>
</html>
Add to the remote.user group in core/routes/web.php:
Route::get('/', \App\Http\Controllers\InboxController::class);
and delete core/resources/views/welcome.blade.php.
- Step 4: Run tests
cd core && php artisan test --filter InboxPageTest
Expected: 2 tests PASS.
- Step 5: Run the whole suite and Pint
cd core && php artisan test && vendor/bin/pint --test
Expected: all green; if Pint reports style issues run vendor/bin/pint and re-run tests.
- Step 6: Commit
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox with presence heartbeat
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
Task 12: Docker Compose, Dockerfiles, Caddy snippet, README
Files:
- Create:
docker-compose.yml,core/Dockerfile,core/Dockerfile.dev,deploy/Caddyfile.snippet,README.md,.env.example(root, for compose)
Interfaces:
-
Produces:
docker compose up -d --buildrunsapp(:8080),scheduler,worker,postgres;docker compose exec app php artisan migrateandhado:source:creatework. -
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, 11 |
§10 /me API, inbox, tz/quiet hours, channels, telegram link |
7, 10, 11 |
| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 12 |
| §12 compose, scheduler, worker, env | 12 |
| §13 tests | every task |
Known simplification vs spec: presence probes run sequentially per user (each cached 60 s) instead of in parallel; only webhook does network I/O, so the difference is at most one 2-second timeout per user per minute. Note it in the spec §9 if it ever matters.