diff --git a/core/app/Enums/AfterDue.php b/core/app/Enums/AfterDue.php new file mode 100644 index 0000000..f246006 --- /dev/null +++ b/core/app/Enums/AfterDue.php @@ -0,0 +1,9 @@ + */ + public static function nonTerminalValues(): array + { + return array_values(array_map( + fn (self $s) => $s->value, + array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()), + )); + } +} diff --git a/core/app/Enums/Presence.php b/core/app/Enums/Presence.php new file mode 100644 index 0000000..760fce9 --- /dev/null +++ b/core/app/Enums/Presence.php @@ -0,0 +1,10 @@ + ChannelType::class, + 'config' => 'array', + 'enabled' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/core/app/Models/Delivery.php b/core/app/Models/Delivery.php new file mode 100644 index 0000000..2f19e63 --- /dev/null +++ b/core/app/Models/Delivery.php @@ -0,0 +1,34 @@ + '{}']; + + 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); + } +} diff --git a/core/app/Models/Event.php b/core/app/Models/Event.php new file mode 100644 index 0000000..d17c244 --- /dev/null +++ b/core/app/Models/Event.php @@ -0,0 +1,50 @@ + 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(); + } +} diff --git a/core/app/Models/Source.php b/core/app/Models/Source.php new file mode 100644 index 0000000..c19692f --- /dev/null +++ b/core/app/Models/Source.php @@ -0,0 +1,16 @@ +hasMany(Event::class); + } +} diff --git a/core/app/Models/User.php b/core/app/Models/User.php index f6ba1d2..bde0c2c 100644 --- a/core/app/Models/User.php +++ b/core/app/Models/User.php @@ -2,31 +2,39 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; -use Database\Factories\UserFactory; -use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Attributes\Hidden; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Foundation\Auth\User as Authenticatable; -use Illuminate\Notifications\Notifiable; +use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['name', 'email', 'password'])] -#[Hidden(['password', 'remember_token'])] -class User extends Authenticatable +class User extends Model { - /** @use HasFactory */ - use HasFactory, Notifiable; + protected $guarded = []; - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array + public function channels(): HasMany { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; + 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); } } diff --git a/core/config/hado.php b/core/config/hado.php new file mode 100644 index 0000000..c0f9a54 --- /dev/null +++ b/core/config/hado.php @@ -0,0 +1,18 @@ + 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, +]; diff --git a/core/database/factories/UserFactory.php b/core/database/factories/UserFactory.php deleted file mode 100644 index c4ceb07..0000000 --- a/core/database/factories/UserFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class UserFactory extends Factory -{ - /** - * The current password being used by the factory. - */ - protected static ?string $password; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - ]; - } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } -} diff --git a/core/database/migrations/0001_01_01_000000_create_hado_tables.php b/core/database/migrations/0001_01_01_000000_create_hado_tables.php new file mode 100644 index 0000000..f75dfed --- /dev/null +++ b/core/database/migrations/0001_01_01_000000_create_hado_tables.php @@ -0,0 +1,91 @@ +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'); + } +}; diff --git a/core/database/migrations/0001_01_01_000000_create_users_table.php b/core/database/migrations/0001_01_01_000000_create_users_table.php deleted file mode 100644 index 05fb5d9..0000000 --- a/core/database/migrations/0001_01_01_000000_create_users_table.php +++ /dev/null @@ -1,49 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - 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(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); - } -}; diff --git a/core/database/seeders/DatabaseSeeder.php b/core/database/seeders/DatabaseSeeder.php index 6b901f8..cccc5c4 100644 --- a/core/database/seeders/DatabaseSeeder.php +++ b/core/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -15,11 +14,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + // } } diff --git a/core/tests/Feature/SchemaTest.php b/core/tests/Feature/SchemaTest.php new file mode 100644 index 0000000..182c030 --- /dev/null +++ b/core/tests/Feature/SchemaTest.php @@ -0,0 +1,91 @@ + $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()); + } +} diff --git a/docs/superpowers/specs/2026-09-03-hado-core-design.md b/docs/superpowers/specs/2026-09-03-hado-core-design.md index 1620822..d305a75 100644 --- a/docs/superpowers/specs/2026-09-03-hado-core-design.md +++ b/docs/superpowers/specs/2026-09-03-hado-core-design.md @@ -93,9 +93,12 @@ deliveries action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала result enum(pending, ok, failed) -- pending ставится в момент диспатча sent_at timestamptz nullable, error text nullable + meta jsonb -- служебное канала, напр. telegram message_id UNIQUE (event_id, channel_id, window_start) ``` +Все `enum(...)` в схеме — строковые колонки, значения фиксирует PHP-enum; так тесты гоняются на SQLite. + Два режима срока. `local` — дата календарная, без пояса; пояс берётся у пользователя в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись сами, пересчитывать нечего. `fixed` — момент абсолютный; «день срока»