Files
muninn/docs/superpowers/plans/2026-07-18-sites-and-accounts.md
T

1473 lines
46 KiB
Markdown
Raw Normal View History

# Sites & Social Accounts Manager 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 a Livewire-based CRUD UI for two independent resources (Sites, Social Accounts) plus a read-only, unauthenticated JSON API for both, so an AI agent can later consume the data over the local network.
**Architecture:** Standard Laravel 13 app. Two Eloquent models (`Site`, `SocialAccount`) each with their own migration, Livewire full-page components for CRUD (index + modal form per resource), and lightweight `Illuminate\Routing\Controller` + API Resource classes for the read-only `/api/sites` and `/api/accounts` endpoints. No auth middleware anywhere. Tests use Pest with `RefreshDatabase` against the existing MySQL connection (test DB `testing`, configured in `phpunit.xml`).
2026-07-18 14:44:24 +03:00
**Tech Stack:** Laravel 13, PHP 8.4, Livewire 4 (Livewire 3 does not support Laravel 13; `composer require livewire/livewire` resolved to v4.3.3 — confirmed during Task 1), Pest 4 (`pestphp/pest-plugin-laravel`), MySQL, Tailwind 4 (already installed) for styling.
## Global Constraints
- No authentication/authorization anywhere (UI or API) — local network only, per spec.
- Passwords stored and returned as plain text — no hashing/encryption, per spec.
- Sites and Social Accounts are fully independent — no foreign key or relationship between them.
- API is read-only: only `index`/`show` routes exist for `/api/sites` and `/api/accounts`. No store/update/destroy API routes.
- `network` enum values: `twitter`, `instagram`, `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `other`.
- `type` enum values: `post`, `comment`, `like`, or `null`.
---
## File Structure
- `database/migrations/xxxx_create_sites_table.php` — new
- `database/migrations/xxxx_create_social_accounts_table.php` — new
- `app/Models/Site.php` — new
- `app/Models/SocialAccount.php` — new
- `database/factories/SiteFactory.php` — new
- `database/factories/SocialAccountFactory.php` — new
- `app/Livewire/Sites/Index.php` + `resources/views/livewire/sites/index.blade.php` — new (list + create/edit/delete UI)
- `app/Livewire/SocialAccounts/Index.php` + `resources/views/livewire/social-accounts/index.blade.php` — new
- `resources/views/components/layouts/app.blade.php` — new (shared nav layout)
- `routes/web.php` — modify (add Livewire page routes)
- `app/Http/Controllers/Api/SiteController.php` — new
- `app/Http/Controllers/Api/SocialAccountController.php` — new
- `app/Http/Resources/SiteResource.php` — new
- `app/Http/Resources/SocialAccountResource.php` — new
- `routes/api.php` — new (doesn't exist yet, needs creating + registering)
- `bootstrap/app.php` — modify (register `api.php` routes)
- `tests/Pest.php` — modify (enable `RefreshDatabase` for Feature tests)
- `tests/Feature/Livewire/SitesTest.php` — new
- `tests/Feature/Livewire/SocialAccountsTest.php` — new
- `tests/Feature/Api/SitesApiTest.php` — new
- `tests/Feature/Api/SocialAccountsApiTest.php` — new
---
## Task 1: Install Livewire and enable RefreshDatabase for tests
**Files:**
- Modify: `composer.json` (via composer require)
- Modify: `tests/Pest.php`
**Interfaces:**
- Produces: Livewire package available (`Livewire\Component`, `Livewire\Livewire` test helper), `RefreshDatabase` applied to all Feature tests.
- [ ] **Step 1: Install Livewire**
Run: `composer require livewire/livewire`
Expected: package installs cleanly, `vendor/livewire` exists.
- [ ] **Step 2: Enable RefreshDatabase in Pest config**
In `tests/Pest.php`, change:
```php
pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class)
->in('Feature');
```
to:
```php
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature');
```
- [ ] **Step 3: Verify the test DB exists and the existing example test still passes**
Run: `php artisan db:show` to confirm connection, then:
Run: `php artisan test tests/Feature/ExampleTest.php`
Expected: PASS (1 test). If the `testing` database doesn't exist, create it first: `mysql -h 127.0.0.1 -u sail -ppassword -e "CREATE DATABASE IF NOT EXISTS testing"` (adjust host/credentials to match your local MySQL setup — check `.env` and `phpunit.xml` for the actual values in use).
- [ ] **Step 4: Commit**
```bash
git add composer.json composer.lock tests/Pest.php
git commit -m "chore: install livewire, enable RefreshDatabase for feature tests"
```
---
## Task 2: Site model, migration, and factory
**Files:**
- Create: `database/migrations/xxxx_create_sites_table.php` (via `php artisan make:migration`)
- Create: `app/Models/Site.php`
- Create: `database/factories/SiteFactory.php`
- Test: `tests/Feature/SiteModelTest.php`
**Interfaces:**
- Produces: `Site` model with fillable `name`, `url`, `description`; `SiteFactory` producing valid random sites for tests.
- [ ] **Step 1: Generate the migration**
Run: `php artisan make:migration create_sites_table`
- [ ] **Step 2: Write the migration**
Edit the generated file in `database/migrations/`:
```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('sites', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('url');
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sites');
}
};
```
- [ ] **Step 3: Write the failing model test**
Create `tests/Feature/SiteModelTest.php`:
```php
<?php
use App\Models\Site;
test('a site can be created with name, url, and optional description', function () {
$site = Site::create([
'name' => 'My Blog',
'url' => 'https://example.com',
'description' => 'A blog about things',
]);
expect($site->fresh())
->name->toBe('My Blog')
->url->toBe('https://example.com')
->description->toBe('A blog about things');
});
test('a site can be created without a description', function () {
$site = Site::create([
'name' => 'My Blog',
'url' => 'https://example.com',
]);
expect($site->fresh()->description)->toBeNull();
});
```
- [ ] **Step 4: Run test to verify it fails**
Run: `php artisan test tests/Feature/SiteModelTest.php`
Expected: FAIL — `Site` class not found, or table doesn't exist.
- [ ] **Step 5: Create the Site model**
Create `app/Models/Site.php`:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Site extends Model
{
use HasFactory;
protected $fillable = [
'name',
'url',
'description',
];
}
```
- [ ] **Step 6: Create the factory**
Create `database/factories/SiteFactory.php`:
```php
<?php
namespace Database\Factories;
use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory;
class SiteFactory extends Factory
{
protected $model = Site::class;
public function definition(): array
{
return [
'name' => fake()->company(),
'url' => fake()->url(),
'description' => fake()->optional()->sentence(),
];
}
}
```
- [ ] **Step 7: Run migrations and test to verify it passes**
Run: `php artisan migrate`
Run: `php artisan test tests/Feature/SiteModelTest.php`
Expected: PASS (2 tests)
- [ ] **Step 8: Commit**
```bash
git add database/migrations database/factories/SiteFactory.php app/Models/Site.php tests/Feature/SiteModelTest.php
git commit -m "feat: add Site model, migration, and factory"
```
---
## Task 3: SocialAccount model, migration, and factory
**Files:**
- Create: `database/migrations/xxxx_create_social_accounts_table.php`
- Create: `app/Models/SocialAccount.php`
- Create: `database/factories/SocialAccountFactory.php`
- Test: `tests/Feature/SocialAccountModelTest.php`
**Interfaces:**
- Produces: `SocialAccount` model with fillable `network`, `username`, `email`, `password`, `type`; constant `SocialAccount::NETWORKS` and `SocialAccount::TYPES` arrays for use in Livewire dropdowns (Task 5) and validation; `SocialAccountFactory`.
- [ ] **Step 1: Generate the migration**
Run: `php artisan make:migration create_social_accounts_table`
- [ ] **Step 2: Write the migration**
```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('social_accounts', function (Blueprint $table) {
$table->id();
$table->enum('network', [
'twitter', 'instagram', 'facebook', 'reddit',
'tiktok', 'linkedin', 'youtube', 'other',
]);
$table->string('username');
$table->string('email');
$table->string('password');
$table->enum('type', ['post', 'comment', 'like'])->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('social_accounts');
}
};
```
- [ ] **Step 3: Write the failing model test**
Create `tests/Feature/SocialAccountModelTest.php`:
```php
<?php
use App\Models\SocialAccount;
test('a social account can be created with all required fields and a type', function () {
$account = SocialAccount::create([
'network' => 'twitter',
'username' => 'my_handle',
'email' => 'handle@example.com',
'password' => 'plain-text-password',
'type' => 'post',
]);
expect($account->fresh())
->network->toBe('twitter')
->username->toBe('my_handle')
->email->toBe('handle@example.com')
->password->toBe('plain-text-password')
->type->toBe('post');
});
test('a social account can be created with a null type', function () {
$account = SocialAccount::create([
'network' => 'instagram',
'username' => 'my_handle',
'email' => 'handle@example.com',
'password' => 'plain-text-password',
]);
expect($account->fresh()->type)->toBeNull();
});
```
- [ ] **Step 4: Run test to verify it fails**
Run: `php artisan test tests/Feature/SocialAccountModelTest.php`
Expected: FAIL — `SocialAccount` class not found.
- [ ] **Step 5: Create the SocialAccount model**
Create `app/Models/SocialAccount.php`:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SocialAccount extends Model
{
use HasFactory;
public const NETWORKS = [
'twitter', 'instagram', 'facebook', 'reddit',
'tiktok', 'linkedin', 'youtube', 'other',
];
public const TYPES = ['post', 'comment', 'like'];
protected $fillable = [
'network',
'username',
'email',
'password',
'type',
];
}
```
- [ ] **Step 6: Create the factory**
Create `database/factories/SocialAccountFactory.php`:
```php
<?php
namespace Database\Factories;
use App\Models\SocialAccount;
use Illuminate\Database\Eloquent\Factories\Factory;
class SocialAccountFactory extends Factory
{
protected $model = SocialAccount::class;
public function definition(): array
{
return [
'network' => fake()->randomElement(SocialAccount::NETWORKS),
'username' => fake()->userName(),
'email' => fake()->safeEmail(),
'password' => fake()->password(),
'type' => fake()->optional()->randomElement(SocialAccount::TYPES),
];
}
}
```
- [ ] **Step 7: Run migrations and test to verify it passes**
Run: `php artisan migrate`
Run: `php artisan test tests/Feature/SocialAccountModelTest.php`
Expected: PASS (2 tests)
- [ ] **Step 8: Commit**
```bash
git add database/migrations database/factories/SocialAccountFactory.php app/Models/SocialAccount.php tests/Feature/SocialAccountModelTest.php
git commit -m "feat: add SocialAccount model, migration, and factory"
```
---
## Task 4: Shared app layout
**Files:**
- Create: `resources/views/components/layouts/app.blade.php`
**Interfaces:**
- Produces: `<x-layouts.app>` Blade component with a `$slot` and a `title` prop, providing `@livewireStyles`/`@livewireScripts` and a top nav linking to Sites and Social Accounts pages. Later Livewire full-page components render inside this via `#[Layout('components.layouts.app')]`.
- [ ] **Step 1: Create the layout**
Create `resources/views/components/layouts/app.blade.php`:
```blade
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Muninn' }}</title>
@vite('resources/css/app.css')
@livewireStyles
</head>
<body class="bg-gray-50 text-gray-900">
<nav class="bg-white border-b border-gray-200 px-6 py-4 flex gap-6">
<a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
<a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
</nav>
<main class="p-6">
{{ $slot }}
</main>
@livewireScripts
</body>
</html>
```
- [ ] **Step 2: Verify `resources/css/app.css` exists (Tailwind entrypoint)**
Run: `cat resources/css/app.css 2>&1 || echo MISSING`
If missing, create `resources/css/app.css`:
```css
@import "tailwindcss";
```
And confirm `vite.config.js` already lists it as an input (check the file — Laravel's default Vite config typically includes `resources/css/app.css` and `resources/js/app.js`). If it's not listed, add it to the `laravel-vite-plugin` `input` array in `vite.config.js`.
- [ ] **Step 3: Commit**
```bash
git add resources/views/components/layouts/app.blade.php resources/css/app.css vite.config.js
git commit -m "feat: add shared app layout with nav"
```
(Note: `route('sites.index')` and `route('social-accounts.index')` won't resolve until Task 5/6 add those routes — this task alone won't be independently testable in the browser, but it introduces no failing automated test since no test targets this file directly. It will be exercised by Task 5's tests.)
---
## Task 5: Sites Livewire CRUD
**Files:**
- Create: `app/Livewire/Sites/Index.php`
- Create: `resources/views/livewire/sites/index.blade.php`
- Modify: `routes/web.php`
- Test: `tests/Feature/Livewire/SitesTest.php`
**Interfaces:**
- Consumes: `App\Models\Site` (Task 2), `components.layouts.app` layout (Task 4).
- Produces: route named `sites.index` at `/sites`; Livewire component `App\Livewire\Sites\Index` with public properties `$name`, `$url`, `$description`, `$editingId`, `$showForm`, and methods `create()`, `edit(int $id)`, `save()`, `delete(int $id)`, `resetForm()`.
- [ ] **Step 1: Write the failing feature test**
Create `tests/Feature/Livewire/SitesTest.php`:
```php
<?php
use App\Livewire\Sites\Index;
use App\Models\Site;
use Livewire\Livewire;
test('sites index page lists existing sites', function () {
$site = Site::factory()->create(['name' => 'Example Site']);
$this->get(route('sites.index'))
->assertOk()
->assertSee('Example Site');
});
test('a new site can be created', function () {
Livewire::test(Index::class)
->set('name', 'New Site')
->set('url', 'https://newsite.test')
->set('description', 'Some description')
->call('save');
expect(Site::where('name', 'New Site')->exists())->toBeTrue();
});
test('name and url are required to create a site', function () {
Livewire::test(Index::class)
->set('name', '')
->set('url', '')
->call('save')
->assertHasErrors(['name', 'url']);
});
test('an existing site can be edited', function () {
$site = Site::factory()->create(['name' => 'Old Name']);
Livewire::test(Index::class)
->call('edit', $site->id)
->set('name', 'Updated Name')
->call('save');
expect($site->fresh()->name)->toBe('Updated Name');
});
test('a site can be deleted', function () {
$site = Site::factory()->create();
Livewire::test(Index::class)->call('delete', $site->id);
expect(Site::find($site->id))->toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `php artisan test tests/Feature/Livewire/SitesTest.php`
Expected: FAIL — route `sites.index` not defined / component class not found.
- [ ] **Step 3: Create the Livewire component**
Run: `php artisan make:livewire Sites/Index --test=false` (or create files manually if the command scaffolds unwanted content)
Replace `app/Livewire/Sites/Index.php` with:
```php
<?php
namespace App\Livewire\Sites;
use App\Models\Site;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('components.layouts.app')]
class Index extends Component
{
public bool $showForm = false;
public ?int $editingId = null;
public string $name = '';
public string $url = '';
public ?string $description = null;
public function render()
{
return view('livewire.sites.index', [
'sites' => Site::orderBy('name')->get(),
]);
}
public function create(): void
{
$this->resetForm();
$this->showForm = true;
}
public function edit(int $id): void
{
$site = Site::findOrFail($id);
$this->editingId = $site->id;
$this->name = $site->name;
$this->url = $site->url;
$this->description = $site->description;
$this->showForm = true;
}
public function save(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'url' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
]);
Site::updateOrCreate(['id' => $this->editingId], $validated);
$this->resetForm();
$this->showForm = false;
}
public function delete(int $id): void
{
Site::findOrFail($id)->delete();
}
public function resetForm(): void
{
$this->editingId = null;
$this->name = '';
$this->url = '';
$this->description = null;
$this->resetValidation();
}
}
```
- [ ] **Step 4: Create the Blade view**
Create `resources/views/livewire/sites/index.blade.php`:
```blade
<div>
<div class="flex justify-between items-center mb-4">
<h1 class="text-xl font-semibold">Sites</h1>
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Site</button>
</div>
<table class="w-full bg-white border border-gray-200 rounded">
<thead>
<tr class="text-left border-b border-gray-200">
<th class="p-3">Name</th>
<th class="p-3">URL</th>
<th class="p-3">Description</th>
<th class="p-3"></th>
</tr>
</thead>
<tbody>
@foreach ($sites as $site)
<tr wire:key="site-{{ $site->id }}" class="border-b border-gray-100">
<td class="p-3">{{ $site->name }}</td>
<td class="p-3"><a href="{{ $site->url }}" target="_blank" class="text-blue-600">{{ $site->url }}</a></td>
<td class="p-3">{{ $site->description }}</td>
<td class="p-3 text-right space-x-2">
<button wire:click="edit({{ $site->id }})" class="text-blue-600">Edit</button>
<button wire:click="delete({{ $site->id }})" wire:confirm="Delete this site?" class="text-red-600">Delete</button>
</td>
</tr>
@endforeach
</tbody>
</table>
@if ($showForm)
<div class="fixed inset-0 bg-black/30 flex items-center justify-center">
<div class="bg-white p-6 rounded w-full max-w-md space-y-4">
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Site' : 'Add Site' }}</h2>
<div>
<label class="block text-sm font-medium">Name</label>
<input type="text" wire:model="name" class="w-full border rounded p-2">
@error('name') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">URL</label>
<input type="text" wire:model="url" class="w-full border rounded p-2">
@error('url') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Description</label>
<textarea wire:model="description" class="w-full border rounded p-2"></textarea>
</div>
<div class="flex justify-end gap-2">
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
</div>
</div>
</div>
@endif
</div>
```
- [ ] **Step 5: Register the route**
In `routes/web.php`, replace the file contents with:
```php
<?php
use App\Livewire\Sites\Index as SitesIndex;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/sites', SitesIndex::class)->name('sites.index');
```
- [ ] **Step 6: Run test to verify it passes**
Run: `php artisan test tests/Feature/Livewire/SitesTest.php`
Expected: PASS (5 tests) — note `sites.index` GET test will still hit `route('social-accounts.index')` in the layout via the nav; that route is added in Task 6, so temporarily this test will fail with "Route [social-accounts.index] not defined" when rendering the layout.
To unblock Task 5 independently, register a placeholder route now so the nav link resolves, replacing it properly in Task 6:
```php
Route::get('/social-accounts', SitesIndex::class)->name('social-accounts.index'); // placeholder, replaced in Task 6
```
Add this line to `routes/web.php` temporarily, rerun the test, confirm PASS, then proceed to Task 6 which replaces the placeholder with the real component.
- [ ] **Step 7: Commit**
```bash
git add app/Livewire/Sites resources/views/livewire/sites routes/web.php tests/Feature/Livewire/SitesTest.php
git commit -m "feat: add Sites Livewire CRUD page"
```
---
## Task 6: Social Accounts Livewire CRUD
**Files:**
- Create: `app/Livewire/SocialAccounts/Index.php`
- Create: `resources/views/livewire/social-accounts/index.blade.php`
- Modify: `routes/web.php` (replace Task 5's placeholder route)
- Test: `tests/Feature/Livewire/SocialAccountsTest.php`
**Interfaces:**
- Consumes: `App\Models\SocialAccount` (Task 3, incl. `NETWORKS`/`TYPES` constants), `components.layouts.app` layout (Task 4).
- Produces: route named `social-accounts.index` at `/social-accounts`; Livewire component `App\Livewire\SocialAccounts\Index` with public properties `$network`, `$username`, `$email`, `$password`, `$type`, `$editingId`, `$showForm`, `$revealedId`, and methods `create()`, `edit(int $id)`, `save()`, `delete(int $id)`, `resetForm()`, `toggleReveal(int $id)`.
- [ ] **Step 1: Write the failing feature test**
Create `tests/Feature/Livewire/SocialAccountsTest.php`:
```php
<?php
use App\Livewire\SocialAccounts\Index;
use App\Models\SocialAccount;
use Livewire\Livewire;
test('social accounts index page lists existing accounts', function () {
SocialAccount::factory()->create(['username' => 'example_user']);
$this->get(route('social-accounts.index'))
->assertOk()
->assertSee('example_user');
});
test('a new social account can be created', function () {
Livewire::test(Index::class)
->set('network', 'twitter')
->set('username', 'new_user')
->set('email', 'new_user@example.com')
->set('password', 'secret123')
->set('type', 'post')
->call('save');
expect(SocialAccount::where('username', 'new_user')->exists())->toBeTrue();
});
test('network, username, email, and password are required', function () {
Livewire::test(Index::class)
->set('network', '')
->set('username', '')
->set('email', '')
->set('password', '')
->call('save')
->assertHasErrors(['network', 'username', 'email', 'password']);
});
test('type can be left null', function () {
Livewire::test(Index::class)
->set('network', 'reddit')
->set('username', 'no_type_user')
->set('email', 'no_type@example.com')
->set('password', 'secret123')
->set('type', null)
->call('save');
$account = SocialAccount::where('username', 'no_type_user')->first();
expect($account->type)->toBeNull();
});
test('an existing account can be edited', function () {
$account = SocialAccount::factory()->create(['username' => 'old_name']);
Livewire::test(Index::class)
->call('edit', $account->id)
->set('username', 'new_name')
->call('save');
expect($account->fresh()->username)->toBe('new_name');
});
test('an account can be deleted', function () {
$account = SocialAccount::factory()->create();
Livewire::test(Index::class)->call('delete', $account->id);
expect(SocialAccount::find($account->id))->toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `php artisan test tests/Feature/Livewire/SocialAccountsTest.php`
Expected: FAIL — route/component not found.
- [ ] **Step 3: Create the Livewire component**
Create `app/Livewire/SocialAccounts/Index.php`:
```php
<?php
namespace App\Livewire\SocialAccounts;
use App\Models\SocialAccount;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('components.layouts.app')]
class Index extends Component
{
public bool $showForm = false;
public ?int $editingId = null;
public ?int $revealedId = null;
public string $network = '';
public string $username = '';
public string $email = '';
public string $password = '';
public ?string $type = null;
public function render()
{
return view('livewire.social-accounts.index', [
'accounts' => SocialAccount::orderBy('username')->get(),
'networks' => SocialAccount::NETWORKS,
'types' => SocialAccount::TYPES,
]);
}
public function create(): void
{
$this->resetForm();
$this->showForm = true;
}
public function edit(int $id): void
{
$account = SocialAccount::findOrFail($id);
$this->editingId = $account->id;
$this->network = $account->network;
$this->username = $account->username;
$this->email = $account->email;
$this->password = $account->password;
$this->type = $account->type;
$this->showForm = true;
}
public function save(): void
{
$validated = $this->validate([
'network' => ['required', 'string', 'in:' . implode(',', SocialAccount::NETWORKS)],
'username' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'max:255'],
'type' => ['nullable', 'string', 'in:' . implode(',', SocialAccount::TYPES)],
]);
SocialAccount::updateOrCreate(['id' => $this->editingId], $validated);
$this->resetForm();
$this->showForm = false;
}
public function delete(int $id): void
{
SocialAccount::findOrFail($id)->delete();
}
public function toggleReveal(int $id): void
{
$this->revealedId = $this->revealedId === $id ? null : $id;
}
public function resetForm(): void
{
$this->editingId = null;
$this->network = '';
$this->username = '';
$this->email = '';
$this->password = '';
$this->type = null;
$this->resetValidation();
}
}
```
- [ ] **Step 4: Create the Blade view**
Create `resources/views/livewire/social-accounts/index.blade.php`:
```blade
<div>
<div class="flex justify-between items-center mb-4">
<h1 class="text-xl font-semibold">Social Accounts</h1>
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Account</button>
</div>
<table class="w-full bg-white border border-gray-200 rounded">
<thead>
<tr class="text-left border-b border-gray-200">
<th class="p-3">Network</th>
<th class="p-3">Username</th>
<th class="p-3">Email</th>
<th class="p-3">Password</th>
<th class="p-3">Type</th>
<th class="p-3"></th>
</tr>
</thead>
<tbody>
@foreach ($accounts as $account)
<tr wire:key="account-{{ $account->id }}" class="border-b border-gray-100">
<td class="p-3">{{ ucfirst($account->network) }}</td>
<td class="p-3">{{ $account->username }}</td>
<td class="p-3">{{ $account->email }}</td>
<td class="p-3">
@if ($revealedId === $account->id)
{{ $account->password }}
@else
••••••••
@endif
<button wire:click="toggleReveal({{ $account->id }})" class="text-xs text-blue-600 ml-2">
{{ $revealedId === $account->id ? 'Hide' : 'Show' }}
</button>
</td>
<td class="p-3">{{ $account->type ? ucfirst($account->type) : '—' }}</td>
<td class="p-3 text-right space-x-2">
<button wire:click="edit({{ $account->id }})" class="text-blue-600">Edit</button>
<button wire:click="delete({{ $account->id }})" wire:confirm="Delete this account?" class="text-red-600">Delete</button>
</td>
</tr>
@endforeach
</tbody>
</table>
@if ($showForm)
<div class="fixed inset-0 bg-black/30 flex items-center justify-center">
<div class="bg-white p-6 rounded w-full max-w-md space-y-4">
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Account' : 'Add Account' }}</h2>
<div>
<label class="block text-sm font-medium">Network</label>
<select wire:model="network" class="w-full border rounded p-2">
<option value="">Select network</option>
@foreach ($networks as $network)
<option value="{{ $network }}">{{ ucfirst($network) }}</option>
@endforeach
</select>
@error('network') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Username</label>
<input type="text" wire:model="username" class="w-full border rounded p-2">
@error('username') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Email</label>
<input type="text" wire:model="email" class="w-full border rounded p-2">
@error('email') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Password</label>
<input type="text" wire:model="password" class="w-full border rounded p-2">
@error('password') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Type</label>
<select wire:model="type" class="w-full border rounded p-2">
<option value="">None</option>
@foreach ($types as $t)
<option value="{{ $t }}">{{ ucfirst($t) }}</option>
@endforeach
</select>
@error('type') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div class="flex justify-end gap-2">
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
</div>
</div>
</div>
@endif
</div>
```
- [ ] **Step 5: Replace the placeholder route in `routes/web.php`**
Update `routes/web.php` to:
```php
<?php
use App\Livewire\Sites\Index as SitesIndex;
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/sites', SitesIndex::class)->name('sites.index');
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
```
- [ ] **Step 6: Run both Livewire test files to verify everything passes**
Run: `php artisan test tests/Feature/Livewire/`
Expected: PASS (11 tests total: 5 from SitesTest + 6 from SocialAccountsTest)
- [ ] **Step 7: Commit**
```bash
git add app/Livewire/SocialAccounts resources/views/livewire/social-accounts routes/web.php tests/Feature/Livewire/SocialAccountsTest.php
git commit -m "feat: add Social Accounts Livewire CRUD page"
```
---
## Task 7: Read-only Sites API
**Files:**
- Create: `app/Http/Controllers/Api/SiteController.php`
- Create: `app/Http/Resources/SiteResource.php`
- Create: `routes/api.php`
- Modify: `bootstrap/app.php`
- Test: `tests/Feature/Api/SitesApiTest.php`
**Interfaces:**
- Consumes: `App\Models\Site` (Task 2).
- Produces: `GET /api/sites` (list, optional `?name=` partial filter), `GET /api/sites/{site}` (show). JSON shape: `{id, name, url, description, created_at, updated_at}`.
- [ ] **Step 1: Write the failing test**
Create `tests/Feature/Api/SitesApiTest.php`:
```php
<?php
use App\Models\Site;
test('it lists all sites', function () {
Site::factory()->count(3)->create();
$response = $this->getJson('/api/sites');
$response->assertOk()->assertJsonCount(3, 'data');
});
test('it filters sites by partial name match', function () {
Site::factory()->create(['name' => 'My Awesome Blog']);
Site::factory()->create(['name' => 'Other Site']);
$response = $this->getJson('/api/sites?name=Awesome');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', 'My Awesome Blog');
});
test('it shows a single site', function () {
$site = Site::factory()->create(['name' => 'Solo Site']);
$response = $this->getJson("/api/sites/{$site->id}");
$response->assertOk()->assertJsonPath('data.name', 'Solo Site');
});
test('it returns 404 for a missing site', function () {
$this->getJson('/api/sites/999')->assertNotFound();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `php artisan test tests/Feature/Api/SitesApiTest.php`
Expected: FAIL — 404 or route not found for `/api/sites`.
- [ ] **Step 3: Create the API Resource**
Create `app/Http/Resources/SiteResource.php`:
```php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SiteResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'url' => $this->url,
'description' => $this->description,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
```
- [ ] **Step 4: Create the controller**
Create `app/Http/Controllers/Api/SiteController.php`:
```php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\SiteResource;
use App\Models\Site;
use Illuminate\Http\Request;
class SiteController extends Controller
{
public function index(Request $request)
{
$query = Site::query();
if ($request->filled('name')) {
$query->where('name', 'like', '%' . $request->string('name') . '%');
}
return SiteResource::collection($query->orderBy('name')->get());
}
public function show(Site $site)
{
return new SiteResource($site);
}
}
```
- [ ] **Step 5: Create `routes/api.php`**
Create `routes/api.php`:
```php
<?php
use App\Http\Controllers\Api\SiteController;
use Illuminate\Support\Facades\Route;
Route::get('/sites', [SiteController::class, 'index']);
Route::get('/sites/{site}', [SiteController::class, 'show']);
```
- [ ] **Step 6: Register the api routes file**
Read `bootstrap/app.php` first to see its current `withRouting` call, then add `api: __DIR__.'/../routes/api.php',` to the routing configuration. For a standard Laravel 13 skeleton, `bootstrap/app.php` looks like:
```php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
```
Add the `api` key so it becomes:
```php
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
```
- [ ] **Step 7: Run test to verify it passes**
Run: `php artisan test tests/Feature/Api/SitesApiTest.php`
Expected: PASS (4 tests)
- [ ] **Step 8: Commit**
```bash
git add app/Http/Controllers/Api/SiteController.php app/Http/Resources/SiteResource.php routes/api.php bootstrap/app.php tests/Feature/Api/SitesApiTest.php
git commit -m "feat: add read-only Sites API"
```
---
## Task 8: Read-only Social Accounts API
**Files:**
- Create: `app/Http/Controllers/Api/SocialAccountController.php`
- Create: `app/Http/Resources/SocialAccountResource.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Api/SocialAccountsApiTest.php`
**Interfaces:**
- Consumes: `App\Models\SocialAccount` (Task 3).
- Produces: `GET /api/accounts` (list, optional `?network=` and `?type=` exact-match filters), `GET /api/accounts/{social_account}` (show). JSON shape includes plain-text `password`: `{id, network, username, email, password, type, created_at, updated_at}`.
- [ ] **Step 1: Write the failing test**
Create `tests/Feature/Api/SocialAccountsApiTest.php`:
```php
<?php
use App\Models\SocialAccount;
test('it lists all social accounts including password', function () {
SocialAccount::factory()->count(3)->create();
$response = $this->getJson('/api/accounts');
$response->assertOk()->assertJsonCount(3, 'data');
expect($response->json('data.0'))->toHaveKey('password');
});
test('it filters accounts by network', function () {
SocialAccount::factory()->create(['network' => 'twitter']);
SocialAccount::factory()->create(['network' => 'instagram']);
$response = $this->getJson('/api/accounts?network=twitter');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.network', 'twitter');
});
test('it filters accounts by type', function () {
SocialAccount::factory()->create(['type' => 'post']);
SocialAccount::factory()->create(['type' => 'like']);
$response = $this->getJson('/api/accounts?type=post');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.type', 'post');
});
test('it shows a single account with password', function () {
$account = SocialAccount::factory()->create(['password' => 'super-secret']);
$response = $this->getJson("/api/accounts/{$account->id}");
$response->assertOk()->assertJsonPath('data.password', 'super-secret');
});
test('it returns 404 for a missing account', function () {
$this->getJson('/api/accounts/999')->assertNotFound();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `php artisan test tests/Feature/Api/SocialAccountsApiTest.php`
Expected: FAIL — route not found.
- [ ] **Step 3: Create the API Resource**
Create `app/Http/Resources/SocialAccountResource.php`:
```php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SocialAccountResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'network' => $this->network,
'username' => $this->username,
'email' => $this->email,
'password' => $this->password,
'type' => $this->type,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
```
- [ ] **Step 4: Create the controller**
Create `app/Http/Controllers/Api/SocialAccountController.php`:
```php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\SocialAccountResource;
use App\Models\SocialAccount;
use Illuminate\Http\Request;
class SocialAccountController extends Controller
{
public function index(Request $request)
{
$query = SocialAccount::query();
if ($request->filled('network')) {
$query->where('network', $request->string('network'));
}
if ($request->filled('type')) {
$query->where('type', $request->string('type'));
}
return SocialAccountResource::collection($query->orderBy('username')->get());
}
public function show(SocialAccount $socialAccount)
{
return new SocialAccountResource($socialAccount);
}
}
```
- [ ] **Step 5: Add routes to `routes/api.php`**
Update `routes/api.php` to:
```php
<?php
use App\Http\Controllers\Api\SiteController;
use App\Http\Controllers\Api\SocialAccountController;
use Illuminate\Support\Facades\Route;
Route::get('/sites', [SiteController::class, 'index']);
Route::get('/sites/{site}', [SiteController::class, 'show']);
Route::get('/accounts', [SocialAccountController::class, 'index']);
Route::get('/accounts/{socialAccount}', [SocialAccountController::class, 'show']);
```
- [ ] **Step 6: Run test to verify it passes**
Run: `php artisan test tests/Feature/Api/SocialAccountsApiTest.php`
Expected: PASS (5 tests)
- [ ] **Step 7: Run the full test suite**
Run: `php artisan test`
Expected: PASS — all tests across the app (example tests, model tests, Livewire tests, API tests) pass.
- [ ] **Step 8: Commit**
```bash
git add app/Http/Controllers/Api/SocialAccountController.php app/Http/Resources/SocialAccountResource.php routes/api.php tests/Feature/Api/SocialAccountsApiTest.php
git commit -m "feat: add read-only Social Accounts API"
```
---
## Task 9: Wire up the home page and manual verification
**Files:**
- Modify: `resources/views/welcome.blade.php`
**Interfaces:**
- Produces: a landing page at `/` linking to `/sites` and `/social-accounts` so the app is navigable from the root URL.
- [ ] **Step 1: Replace the welcome view**
Replace the entire contents of `resources/views/welcome.blade.php` with:
```blade
<x-layouts.app title="Muninn">
<div class="max-w-xl mx-auto text-center space-y-6 mt-20">
<h1 class="text-2xl font-bold">Muninn</h1>
<p class="text-gray-600">Manage your sites and social accounts.</p>
<div class="flex justify-center gap-4">
<a href="{{ route('sites.index') }}" class="bg-blue-600 text-white px-4 py-2 rounded">View Sites</a>
<a href="{{ route('social-accounts.index') }}" class="bg-blue-600 text-white px-4 py-2 rounded">View Social Accounts</a>
</div>
</div>
</x-layouts.app>
```
- [ ] **Step 2: Update `tests/Feature/ExampleTest.php` to match the new welcome content**
The existing test only asserts a 200 status, so no change is strictly required. Run it to confirm:
Run: `php artisan test tests/Feature/ExampleTest.php`
Expected: PASS
- [ ] **Step 3: Manually verify in the browser**
Run: `npm run build` (or start `npm run dev` in one terminal and `php artisan serve` in another)
Visit `http://localhost:8000/` and confirm:
- The home page links to Sites and Social Accounts
- `/sites`: can add, edit, delete a site
- `/social-accounts`: can add, edit, delete an account; password show/hide toggle works
- `GET /api/sites` and `GET /api/accounts` (e.g. via `curl http://localhost:8000/api/sites`) return JSON matching what's in the UI
- [ ] **Step 4: Commit**
```bash
git add resources/views/welcome.blade.php
git commit -m "feat: add home page linking to Sites and Social Accounts"
```
---
## Self-Review Notes
- **Spec coverage:** Sites CRUD (Task 5), Social Accounts CRUD (Task 6), read-only API with filters for both resources (Tasks 7–8), no-auth throughout (no middleware added anywhere), plain-text password storage/display (Tasks 3, 6, 8) — all spec requirements are covered.
- **Placeholder scan:** No TBD/TODO markers; Task 5 Step 6 contains a temporary placeholder route explicitly called out and immediately resolved in Task 6 Step 5 — this is a real, working intermediate state, not an unfinished placeholder.
- **Type consistency:** `Site` fillable fields (`name`, `url`, `description`) match across model, factory, Livewire component, and API resource. `SocialAccount` fillable fields (`network`, `username`, `email`, `password`, `type`) and the `NETWORKS`/`TYPES` constants are consistent across model, factory, Livewire component/view, and API resource/controller.