Initial project

This commit is contained in:
2026-05-01 00:41:02 +03:30
parent d324115341
commit cde71d5761
172 changed files with 22074 additions and 12 deletions

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,52 @@
<?php
namespace Database\Factories;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Conversation>
*/
class ConversationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'created_by_id' => User::factory(),
'type' => Conversation::TypeDirect,
'name' => null,
'description' => null,
];
}
public function direct(): static
{
return $this->state(fn (array $attributes) => [
'type' => Conversation::TypeDirect,
'name' => null,
'description' => null,
]);
}
public function group(): static
{
return $this->state(fn (array $attributes) => [
'type' => Conversation::TypeGroup,
'name' => fake()->randomElement([
'Design Partners',
'Product Launch',
'Customer Success',
'Engineering Standup',
'Growth Studio',
]),
'description' => fake()->sentence(8),
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Database\Factories;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ConversationParticipant>
*/
class ConversationParticipantFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'conversation_id' => Conversation::factory(),
'user_id' => User::factory(),
'role' => ConversationParticipant::RoleMember,
'joined_at' => fake()->dateTimeBetween('-2 months', 'now'),
'last_read_at' => fake()->optional(0.85)->dateTimeBetween('-2 weeks', 'now'),
'muted_until' => null,
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => ConversationParticipant::RoleAdmin,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Database\Factories;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Message>
*/
class MessageFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'conversation_id' => Conversation::factory(),
'user_id' => User::factory(),
'type' => Message::TypeText,
'body' => fake()->randomElement([
fake()->sentence(8),
fake()->sentence(12),
fake()->paragraph(2),
'I pushed a cleaner version. Can you take a look?',
'This is ready from my side.',
'Let me know what you think about the latest update.',
]),
'metadata' => null,
'edited_at' => null,
];
}
}

View File

@@ -0,0 +1,60 @@
<?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),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
/**
* Indicate that the model has two-factor authentication configured.
*/
public function withTwoFactor(): static
{
return $this->state(fn (array $attributes) => [
'two_factor_secret' => encrypt('secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
'two_factor_confirmed_at' => now(),
]);
}
}

View File

@@ -0,0 +1,49 @@
<?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

@@ -0,0 +1,35 @@
<?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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?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('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,34 @@
<?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::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->after('password')->nullable();
$table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable();
$table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@@ -0,0 +1,33 @@
<?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('conversations', function (Blueprint $table) {
$table->id();
$table->foreignId('created_by_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('type')->default('direct')->index();
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->timestamps();
$table->index(['type', 'updated_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('conversations');
}
};

View File

@@ -0,0 +1,36 @@
<?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('conversation_participants', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member');
$table->timestamp('joined_at')->nullable();
$table->timestamp('last_read_at')->nullable();
$table->timestamp('muted_until')->nullable();
$table->timestamps();
$table->unique(['conversation_id', 'user_id']);
$table->index(['user_id', 'updated_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('conversation_participants');
}
};

View File

@@ -0,0 +1,36 @@
<?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('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('type')->default('text')->index();
$table->text('body');
$table->json('metadata')->nullable();
$table->timestamp('edited_at')->nullable();
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
$table->index(['user_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

@@ -0,0 +1,117 @@
<?php
namespace Database\Seeders;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
$user = User::query()->firstOrCreate([
'email' => 'test@example.com',
], [
'name' => 'Test User',
'email_verified_at' => now(),
'password' => 'password',
]);
$teammates = User::factory()
->count(10)
->create();
$teammates->take(5)->each(function (User $teammate, int $index) use ($user): void {
$conversation = Conversation::factory()
->direct()
->for($user, 'creator')
->create(['updated_at' => now()->subMinutes(12 - $index)]);
ConversationParticipant::factory()
->for($conversation)
->for($user)
->create([
'joined_at' => now()->subWeeks(3),
'last_read_at' => $index < 2 ? now()->subMinutes(20) : now(),
]);
ConversationParticipant::factory()
->for($conversation)
->for($teammate)
->create([
'joined_at' => now()->subWeeks(3),
'last_read_at' => now()->subHour(),
]);
$this->seedMessages($conversation, collect([$user, $teammate])->values()->all(), 8, $index);
});
$groups = [
['name' => 'Product Launch', 'description' => 'Messaging for launch readiness, blockers, and decisions.'],
['name' => 'Design Partners', 'description' => 'Feedback loops with design, research, and product.'],
['name' => 'Customer Success', 'description' => 'Escalations, onboarding notes, and customer wins.'],
];
foreach ($groups as $groupIndex => $group) {
$conversation = Conversation::factory()
->group()
->for($user, 'creator')
->create([
'name' => $group['name'],
'description' => $group['description'],
'updated_at' => now()->subMinutes(5 + $groupIndex),
]);
$participants = $teammates
->slice($groupIndex * 3, 4)
->push($user)
->values();
$participants->each(function (User $participant) use ($conversation, $user): void {
ConversationParticipant::factory()
->for($conversation)
->for($participant)
->create([
'role' => $participant->is($user) ? ConversationParticipant::RoleAdmin : ConversationParticipant::RoleMember,
'joined_at' => now()->subMonth(),
'last_read_at' => $participant->is($user) ? now()->subMinutes(30) : now()->subDay(),
]);
});
$this->seedMessages($conversation, $participants->all(), 14, $groupIndex + 5);
}
}
/**
* @param array<int, User> $participants
*/
private function seedMessages(Conversation $conversation, array $participants, int $count, int $offset): void
{
$startedAt = now()
->subDays(3)
->addHours($offset * 2);
for ($messageIndex = 0; $messageIndex < $count; $messageIndex++) {
$sender = $participants[$messageIndex % count($participants)];
$createdAt = $startedAt->copy()->addMinutes($messageIndex * 27);
Message::factory()
->for($conversation)
->for($sender, 'sender')
->create([
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
$conversation->forceFill([
'updated_at' => $startedAt->copy()->addMinutes($count * 27),
])->save();
}
}