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
This commit is contained in:
nikita.hohlov
2026-09-04 06:05:34 -03:00
parent 8d01b7050d
commit 774e0bce1a
18 changed files with 447 additions and 123 deletions

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum AfterDue: string
{
case Keep = 'keep';
case Expire = 'expire';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ChannelType: string
{
case Web = 'web';
case Telegram = 'telegram';
case Webhook = 'webhook';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum DeliveryResult: string
{
case Pending = 'pending';
case Ok = 'ok';
case Failed = 'failed';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum DueMode: string
{
case Local = 'local';
case Fixed = 'fixed';
}

View File

@@ -0,0 +1,39 @@
<?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()),
));
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum Presence: string
{
case Present = 'present';
case Absent = 'absent';
case Unknown = 'unknown';
}

View File

@@ -0,0 +1,26 @@
<?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);
}
}

View File

@@ -0,0 +1,34 @@
<?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);
}
}

50
core/app/Models/Event.php Normal file
View File

@@ -0,0 +1,50 @@
<?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();
}
}

View File

@@ -0,0 +1,16 @@
<?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);
}
}

View File

@@ -2,31 +2,39 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use Carbon\CarbonImmutable;
use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])] class User extends Model
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ protected $guarded = [];
use HasFactory, Notifiable;
/** public function channels(): HasMany
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{ {
return [ return $this->hasMany(Channel::class);
'email_verified_at' => 'datetime', }
'password' => 'hashed',
]; 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);
} }
} }

18
core/config/hado.php Normal file
View File

@@ -0,0 +1,18 @@
<?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,
];

View File

@@ -1,45 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
]);
}
}

View File

@@ -0,0 +1,91 @@
<?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');
}
};

View File

@@ -1,49 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -15,11 +14,6 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); //
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
} }
} }

View File

@@ -0,0 +1,91 @@
<?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());
}
}

View File

@@ -93,9 +93,12 @@ deliveries
action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала
result enum(pending, ok, failed) -- pending ставится в момент диспатча result enum(pending, ok, failed) -- pending ставится в момент диспатча
sent_at timestamptz nullable, error text nullable sent_at timestamptz nullable, error text nullable
meta jsonb -- служебное канала, напр. telegram message_id
UNIQUE (event_id, channel_id, window_start) UNIQUE (event_id, channel_id, window_start)
``` ```
Все `enum(...)` в схеме — строковые колонки, значения фиксирует PHP-enum; так тесты гоняются на SQLite.
Два режима срока. `local` — дата календарная, без пояса; пояс берётся у Два режима срока. `local` — дата календарная, без пояса; пояс берётся у
пользователя в момент вычисления. Переехал в другую страну, сменил `tz` — все окна пользователя в момент вычисления. Переехал в другую страну, сменил `tz` — все окна
сдвинулись сами, пересчитывать нечего. `fixed` — момент абсолютный; «день срока» сдвинулись сами, пересчитывать нечего. `fixed` — момент абсолютный; «день срока»