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:
9
core/app/Enums/AfterDue.php
Normal file
9
core/app/Enums/AfterDue.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AfterDue: string
|
||||
{
|
||||
case Keep = 'keep';
|
||||
case Expire = 'expire';
|
||||
}
|
||||
10
core/app/Enums/ChannelType.php
Normal file
10
core/app/Enums/ChannelType.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ChannelType: string
|
||||
{
|
||||
case Web = 'web';
|
||||
case Telegram = 'telegram';
|
||||
case Webhook = 'webhook';
|
||||
}
|
||||
10
core/app/Enums/DeliveryResult.php
Normal file
10
core/app/Enums/DeliveryResult.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DeliveryResult: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Ok = 'ok';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
9
core/app/Enums/DueMode.php
Normal file
9
core/app/Enums/DueMode.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DueMode: string
|
||||
{
|
||||
case Local = 'local';
|
||||
case Fixed = 'fixed';
|
||||
}
|
||||
39
core/app/Enums/EventState.php
Normal file
39
core/app/Enums/EventState.php
Normal 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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
10
core/app/Enums/Presence.php
Normal file
10
core/app/Enums/Presence.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum Presence: string
|
||||
{
|
||||
case Present = 'present';
|
||||
case Absent = 'absent';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
26
core/app/Models/Channel.php
Normal file
26
core/app/Models/Channel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
core/app/Models/Delivery.php
Normal file
34
core/app/Models/Delivery.php
Normal 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
50
core/app/Models/Event.php
Normal 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();
|
||||
}
|
||||
}
|
||||
16
core/app/Models/Source.php
Normal file
16
core/app/Models/Source.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
18
core/config/hado.php
Normal file
18
core/config/hado.php
Normal 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,
|
||||
];
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
91
core/tests/Feature/SchemaTest.php
Normal file
91
core/tests/Feature/SchemaTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user