feat: create brands with CRUD, policy, and tests
This commit is contained in:
parent
5bc1548423
commit
c107ce3949
17 changed files with 479 additions and 3 deletions
103
app/Http/Controllers/App/BrandController.php
Normal file
103
app/Http/Controllers/App/BrandController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Brand/StoreBrandRequest.php
Normal file
25
app/Http/Requests/App/Brand/StoreBrandRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Brand/UpdateBrandRequest.php
Normal file
25
app/Http/Requests/App/Brand/UpdateBrandRequest.php
Normal 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
33
app/Models/Brand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
58
app/Policies/BrandPolicy.php
Normal file
58
app/Policies/BrandPolicy.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
28
database/factories/BrandFactory.php
Normal file
28
database/factories/BrandFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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');
|
||||
|
|
|
|||
108
tests/Feature/BrandControllerTest.php
Normal file
108
tests/Feature/BrandControllerTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue