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

5718 lines
218 KiB
Markdown
Raw Blame History

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