feat: create brands with CRUD, policy, and tests

This commit is contained in:
Paulo Castellano 2026-04-14 18:04:41 -03:00
parent 5bc1548423
commit c107ce3949
17 changed files with 479 additions and 3 deletions

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Http\Requests\App\Brand\StoreBrandRequest;
use App\Http\Requests\App\Brand\UpdateBrandRequest;
use App\Models\Brand;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class BrandController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('viewAny', [Brand::class, $workspace]);
$brands = $workspace->brands()
->withCount('socialAccounts')
->latest()
->paginate(config('app.pagination.default'));
return Inertia::render('brands/Index', [
'brands' => Inertia::scroll(fn () => $brands),
'canCreate' => $request->user()->can('create', [Brand::class, $workspace]),
]);
}
public function store(StoreBrandRequest $request): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('create', [Brand::class, $workspace]);
$workspace->brands()->create([
'name' => data_get($request->validated(), 'name'),
]);
session()->flash('flash.banner', __('Brand created successfully.'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.brands.index');
}
public function update(UpdateBrandRequest $request, Brand $brand): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('update', $brand);
if ($brand->workspace_id !== $workspace->id) {
abort(403);
}
$brand->update([
'name' => data_get($request->validated(), 'name'),
]);
session()->flash('flash.banner', __('Brand updated successfully.'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.brands.index');
}
public function destroy(Brand $brand): RedirectResponse
{
$workspace = request()->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('delete', $brand);
if ($brand->workspace_id !== $workspace->id) {
abort(403);
}
$brand->delete();
session()->flash('flash.banner', __('Brand deleted successfully.'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.brands.index');
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Brand;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Brand;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}

33
app/Models/Brand.php Normal file
View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\BrandFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Brand extends Model
{
/** @use HasFactory<BrandFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'workspace_id',
'name',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
}

View file

@ -28,6 +28,7 @@ class SocialAccount extends Model
protected $fillable = [
'workspace_id',
'brand_id',
'platform',
'platform_user_id',
'username',
@ -69,6 +70,11 @@ public function workspace(): BelongsTo
return $this->belongsTo(Workspace::class);
}
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function postPlatforms(): HasMany
{
return $this->hasMany(PostPlatform::class);

View file

@ -20,6 +20,7 @@ class Workspace extends Model
protected $fillable = [
'user_id',
'plan_id',
'name',
'timezone',
];
@ -41,6 +42,11 @@ public function owner(): BelongsTo
return $this->belongsTo(User::class, 'user_id');
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)
@ -73,6 +79,11 @@ public function labels(): HasMany
return $this->hasMany(WorkspaceLabel::class);
}
public function brands(): HasMany
{
return $this->hasMany(Brand::class);
}
public function apiTokens(): HasMany
{
return $this->hasMany(ApiToken::class);

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Enums\UserWorkspace\Role;
use App\Models\Brand;
use App\Models\User;
use App\Models\Workspace;
class BrandPolicy
{
public function viewAny(User $user, Workspace $workspace): bool
{
return $workspace->members()->where('user_id', $user->id)->exists();
}
public function create(User $user, Workspace $workspace): bool
{
if (! $this->canManage($user, $workspace)) {
return false;
}
if (config('trypost.self_hosted')) {
return true;
}
$plan = $workspace->plan;
if (! $plan) {
return false;
}
return $workspace->brands()->count() < $plan->brand_limit;
}
public function update(User $user, Brand $brand): bool
{
return $this->canManage($user, $brand->workspace);
}
public function delete(User $user, Brand $brand): bool
{
return $this->canManage($user, $brand->workspace);
}
private function canManage(User $user, Workspace $workspace): bool
{
$member = $workspace->members()->where('user_id', $user->id)->first();
if (! $member) {
return false;
}
return in_array(Role::tryFrom($member->pivot->role), [Role::Owner, Role::Admin]);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Providers;
use App\Listeners\StripeEventListener;
use App\Models\Brand;
use App\Models\Media;
use App\Models\Notification;
use App\Models\NotificationPreference;
@ -81,6 +82,7 @@ public function boot(): void
protected function configureMorphMap(): void
{
Relation::enforceMorphMap([
'brand' => Brand::class,
'media' => Media::class,
'notification' => Notification::class,
'plan' => Plan::class,

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Brand;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Brand>
*/
class BrandFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'name' => fake()->company(),
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
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('brands', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('brands');
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
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::table('social_accounts', function (Blueprint $table) {
$table->foreignUuid('brand_id')->nullable()->after('workspace_id')->constrained()->nullOnDelete();
});
}
public function down(): void
{
Schema::table('social_accounts', function (Blueprint $table) {
$table->dropConstrainedForeignId('brand_id');
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
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::table('workspaces', function (Blueprint $table) {
$table->foreignUuid('plan_id')->nullable()->after('user_id')->constrained()->nullOnDelete();
});
}
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropConstrainedForeignId('plan_id');
});
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@
use App\Http\Controllers\App\AnalyticsController;
use App\Http\Controllers\App\ApiKeyController;
use App\Http\Controllers\App\BillingController;
use App\Http\Controllers\App\BrandController;
use App\Http\Controllers\App\MediaController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
@ -163,6 +164,12 @@
Route::put('labels/{label}', [WorkspaceLabelController::class, 'update'])->name('app.labels.update');
Route::delete('labels/{label}', [WorkspaceLabelController::class, 'destroy'])->name('app.labels.destroy');
// Brands
Route::get('brands', [BrandController::class, 'index'])->name('app.brands.index');
Route::post('brands', [BrandController::class, 'store'])->name('app.brands.store');
Route::put('brands/{brand}', [BrandController::class, 'update'])->name('app.brands.update');
Route::delete('brands/{brand}', [BrandController::class, 'destroy'])->name('app.brands.destroy');
// API Keys
Route::get('api-keys', [ApiKeyController::class, 'index'])->name('app.api-keys.index');
Route::post('api-keys', [ApiKeyController::class, 'store'])->name('app.api-keys.store');

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Http\Middleware\App\EnsureSubscribed;
use App\Models\Brand;
use App\Models\Plan;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
config(['trypost.self_hosted' => true]);
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can list brands', function () {
Brand::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('app.brands.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('brands/Index', false)
->has('brands.data', 2)
);
});
test('can create brand', function () {
$response = $this->actingAs($this->user)->post(route('app.brands.store'), [
'name' => 'My Brand',
]);
$response->assertRedirect(route('app.brands.index'));
$this->assertDatabaseHas('brands', [
'workspace_id' => $this->workspace->id,
'name' => 'My Brand',
]);
});
test('can update brand name', function () {
$brand = Brand::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Old Name']);
$response = $this->actingAs($this->user)->put(route('app.brands.update', $brand), [
'name' => 'New Name',
]);
$response->assertRedirect(route('app.brands.index'));
$brand->refresh();
expect($brand->name)->toBe('New Name');
});
test('can delete brand', function () {
$brand = Brand::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('app.brands.destroy', $brand));
$response->assertRedirect(route('app.brands.index'));
expect(Brand::find($brand->id))->toBeNull();
});
test('deleting brand nullifies social account brand_id', function () {
$brand = Brand::factory()->create(['workspace_id' => $this->workspace->id]);
$socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'brand_id' => $brand->id,
]);
$this->actingAs($this->user)->delete(route('app.brands.destroy', $brand));
$socialAccount->refresh();
expect($socialAccount->brand_id)->toBeNull();
});
test('cannot create brand beyond plan limit', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::query()->first() ?? Plan::factory()->create();
$plan->update(['brand_limit' => 0]);
$this->workspace->update(['plan_id' => $plan->id]);
$response = $this->withoutMiddleware(EnsureSubscribed::class)
->actingAs($this->user)
->post(route('app.brands.store'), [
'name' => 'Should Fail',
]);
$response->assertForbidden();
});
test('cannot access brands from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$brand = Brand::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->put(route('app.brands.update', $brand), [
'name' => 'Hacked',
]);
$response->assertForbidden();
});