feat: introduce Account entity as billing owner and refactor architecture
- Create Account model as Cashier Billable entity (stripe, plan, subscription) - Account owns workspaces and has an owner_id (User) - User belongs to one Account via account_id - Workspace belongs to Account via account_id, no longer has billing fields - Remove Brand model entirely (workspaces serve as grouping) - Rename brand_limit to workspace_limit in plans - Workspace roles simplified: admin/member/viewer (owner via Account) - Invites now belong to Account with workspaces JSON array - Pennant features scope changed from Workspace to Account - EnsureSubscribed middleware checks Account subscription - All controllers updated: BillingController, OnboardingController, WorkspaceInviteController, SocialController, StripeEventListener - Frontend: extract GoogleAuthButton component, create WorkspaceRole enum for type-safe role checks, fix all views for new architecture - All 1101 tests passing
This commit is contained in:
parent
ba27688c59
commit
2da15df96c
138 changed files with 1468 additions and 1839 deletions
|
|
@ -4,19 +4,20 @@
|
|||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceInvite;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class CreateInvite
|
||||
{
|
||||
public static function execute(Workspace $workspace, array $data): WorkspaceInvite
|
||||
public static function execute(Workspace $workspace, array $data): Invite
|
||||
{
|
||||
$invite = $workspace->invites()->create([
|
||||
$invite = Invite::create([
|
||||
'account_id' => $workspace->account_id,
|
||||
'invited_by' => auth()->id(),
|
||||
'email' => data_get($data, 'email'),
|
||||
'role' => data_get($data, 'role', WorkspaceRole::Member),
|
||||
'workspaces' => [$workspace->id],
|
||||
]);
|
||||
|
||||
Mail::to($invite->email)->send(new WorkspaceInviteMail($invite));
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Models\WorkspaceInvite;
|
||||
use App\Models\Invite;
|
||||
|
||||
class DeleteInvite
|
||||
{
|
||||
public static function execute(WorkspaceInvite $invite): void
|
||||
public static function execute(Invite $invite): void
|
||||
{
|
||||
$invite->delete();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -20,21 +21,29 @@ public static function execute(array $data): User
|
|||
return DB::transaction(function () use ($data): User {
|
||||
$isInviteRegistration = data_get($data, 'is_invite', false);
|
||||
|
||||
$account = Account::create([
|
||||
'name' => data_get($data, 'name')."'s Account",
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => data_get($data, 'name'),
|
||||
'email' => data_get($data, 'email'),
|
||||
'password' => data_get($data, 'password'),
|
||||
'setup' => data_get($data, 'setup', $isInviteRegistration ? Setup::Completed : Setup::Role),
|
||||
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$account->update(['owner_id' => $user->id]);
|
||||
|
||||
$workspace = Workspace::create([
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name."'s Workspace",
|
||||
'timezone' => data_get($data, 'timezone', 'UTC'),
|
||||
]);
|
||||
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,13 @@ class CreateWorkspace
|
|||
public static function execute(User $user, array $data): Workspace
|
||||
{
|
||||
$workspace = Workspace::create([
|
||||
'account_id' => $user->account_id,
|
||||
'user_id' => $user->id,
|
||||
...$data,
|
||||
'timezone' => config('app.timezone', 'UTC'),
|
||||
]);
|
||||
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->switchWorkspace($workspace);
|
||||
|
||||
return $workspace;
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ public static function execute(User $user, Workspace $workspace): void
|
|||
{
|
||||
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
|
||||
|
||||
if (! config('trypost.self_hosted') && $workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) {
|
||||
$workspace->subscription(Workspace::SUBSCRIPTION_NAME)->cancel();
|
||||
}
|
||||
|
||||
$workspace->delete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,32 +6,16 @@
|
|||
|
||||
enum Role: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Admin = 'admin';
|
||||
case Member = 'member';
|
||||
case Viewer = 'viewer';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner => 'Owner',
|
||||
self::Admin => 'Admin',
|
||||
self::Member => 'Member',
|
||||
};
|
||||
}
|
||||
|
||||
public function canManageTeam(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner, self::Admin => true,
|
||||
self::Member => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function canManageAccounts(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner, self::Admin => true,
|
||||
self::Member => false,
|
||||
self::Viewer => 'Viewer',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
|
|
@ -15,12 +15,12 @@ class SubscriptionCreated implements ShouldBroadcastNow
|
|||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public Workspace $workspace) {}
|
||||
public function __construct(public Account $account) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('users.'.$this->workspace->user_id),
|
||||
new PrivateChannel('users.'.$this->account->owner_id),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
|
||||
class AiImagesLimit
|
||||
{
|
||||
public function resolve(Workspace $scope): int
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->ai_images_limit ?? 50;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
|
||||
class AiVideosLimit
|
||||
{
|
||||
public function resolve(Workspace $scope): int
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->ai_videos_limit ?? 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Workspace;
|
||||
|
||||
class BrandLimit
|
||||
{
|
||||
public function resolve(Workspace $scope): int
|
||||
{
|
||||
return $scope->plan?->brand_limit ?? 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
|
||||
class DataRetentionDays
|
||||
{
|
||||
public function resolve(Workspace $scope): int
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->data_retention_days ?? 30;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
|
||||
class MemberLimit
|
||||
{
|
||||
public function resolve(Workspace $scope): int
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->member_limit ?? 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
|
||||
class SocialAccountLimit
|
||||
{
|
||||
public function resolve(Workspace $scope): int
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->social_account_limit ?? 5;
|
||||
}
|
||||
|
|
|
|||
15
app/Features/WorkspaceLimit.php
Normal file
15
app/Features/WorkspaceLimit.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Account;
|
||||
|
||||
class WorkspaceLimit
|
||||
{
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->workspace_limit ?? 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -16,9 +16,9 @@ class BillingController extends Controller
|
|||
{
|
||||
public function subscribe(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$account = $request->user()->account;
|
||||
|
||||
if ($workspace && $workspace->hasActiveSubscription()) {
|
||||
if ($account && $account->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index');
|
||||
}
|
||||
|
||||
|
|
@ -30,9 +30,9 @@ public function subscribe(Request $request): Response|RedirectResponse
|
|||
|
||||
public function checkout(Request $request, Plan $plan): SymfonyResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$account = $request->user()->account;
|
||||
|
||||
$this->authorize('manageBilling', $workspace);
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
||||
$priceId = $request->input('interval', 'monthly') === 'yearly'
|
||||
? $plan->stripe_yearly_price_id
|
||||
|
|
@ -40,12 +40,12 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
|
|||
|
||||
abort_if(! $priceId, 422, 'Plan price not configured');
|
||||
|
||||
$workspace->createOrGetStripeCustomer([
|
||||
'email' => $workspace->stripeEmail(),
|
||||
'name' => $workspace->stripeName(),
|
||||
$account->createOrGetStripeCustomer([
|
||||
'email' => $account->stripeEmail(),
|
||||
'name' => $account->stripeName(),
|
||||
]);
|
||||
|
||||
$subscription = $workspace->newSubscription(Workspace::SUBSCRIPTION_NAME, $priceId)
|
||||
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
||||
->allowPromotionCodes()
|
||||
->trialDays(config('cashier.trial_days'));
|
||||
|
||||
|
|
@ -54,17 +54,17 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
|
|||
'cancel_url' => route('app.billing.processing').'?status=cancelled',
|
||||
]);
|
||||
|
||||
$workspace->update(['plan_id' => $plan->id]);
|
||||
$account->update(['plan_id' => $plan->id]);
|
||||
|
||||
return Inertia::location($checkoutSession->url);
|
||||
}
|
||||
|
||||
public function processing(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$account = $request->user()->account;
|
||||
$status = $request->query('status', 'processing');
|
||||
|
||||
if ($workspace && $workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) {
|
||||
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
|
|
@ -73,37 +73,37 @@ public function processing(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
return Inertia::render('billing/Processing', [
|
||||
'workspaceId' => $workspace?->id,
|
||||
'accountId' => $account?->id,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$account = $request->user()->account;
|
||||
|
||||
$this->authorize('manageBilling', $workspace);
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
||||
$subscription = $workspace->subscription(Workspace::SUBSCRIPTION_NAME);
|
||||
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
|
||||
|
||||
return Inertia::render('billing/Index', [
|
||||
'hasSubscription' => $workspace->subscribed(Workspace::SUBSCRIPTION_NAME),
|
||||
'hasSubscription' => $account->subscribed(Account::SUBSCRIPTION_NAME),
|
||||
'onTrial' => $subscription?->onTrial() ?? false,
|
||||
'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(),
|
||||
'subscription' => $subscription?->only([
|
||||
'stripe_status',
|
||||
'ends_at',
|
||||
]),
|
||||
'plan' => $workspace->plan,
|
||||
'plan' => $account->plan,
|
||||
'plans' => Plan::active()->orderBy('sort')->get(),
|
||||
'invoices' => $workspace->invoices()->map(fn ($invoice) => [
|
||||
'invoices' => $account->invoices()->map(fn ($invoice) => [
|
||||
'id' => $invoice->id,
|
||||
'date' => $invoice->date()->toFormattedDateString(),
|
||||
'total' => $invoice->total(),
|
||||
'status' => $invoice->status,
|
||||
'invoice_pdf' => $invoice->invoice_pdf,
|
||||
]),
|
||||
'defaultPaymentMethod' => $workspace->defaultPaymentMethod()?->card?->only([
|
||||
'defaultPaymentMethod' => $account->defaultPaymentMethod()?->card?->only([
|
||||
'brand',
|
||||
'last4',
|
||||
'exp_month',
|
||||
|
|
@ -114,11 +114,10 @@ public function index(Request $request): Response
|
|||
|
||||
public function swap(Request $request, Plan $plan): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$account = $request->user()->account;
|
||||
|
||||
$this->authorize('manageBilling', $workspace);
|
||||
|
||||
abort_unless($workspace->subscribed(Workspace::SUBSCRIPTION_NAME), 422, 'No active subscription');
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
|
||||
|
||||
$priceId = $request->input('interval', 'monthly') === 'yearly'
|
||||
? $plan->stripe_yearly_price_id
|
||||
|
|
@ -126,19 +125,19 @@ public function swap(Request $request, Plan $plan): RedirectResponse
|
|||
|
||||
abort_if(! $priceId, 422, 'Plan price not configured');
|
||||
|
||||
$workspace->subscription(Workspace::SUBSCRIPTION_NAME)->swap($priceId);
|
||||
$workspace->update(['plan_id' => $plan->id]);
|
||||
$account->subscription(Account::SUBSCRIPTION_NAME)->swap($priceId);
|
||||
$account->update(['plan_id' => $plan->id]);
|
||||
|
||||
return redirect()->route('app.billing.index');
|
||||
}
|
||||
|
||||
public function portal(Request $request): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$account = $request->user()->account;
|
||||
|
||||
$this->authorize('manageBilling', $workspace);
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
||||
return $workspace->redirectToBillingPortal(
|
||||
return $account->redirectToBillingPortal(
|
||||
route('app.billing.index')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
<?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', __('brands.flash.created'));
|
||||
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);
|
||||
|
||||
$brand->update([
|
||||
'name' => data_get($request->validated(), 'name'),
|
||||
]);
|
||||
|
||||
session()->flash('flash.banner', __('brands.flash.updated'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.brands.index');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Brand $brand): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('delete', $brand);
|
||||
|
||||
$brand->delete();
|
||||
|
||||
session()->flash('flash.banner', __('brands.flash.deleted'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.brands.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\User\Persona;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -92,14 +92,15 @@ public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
|
|||
|
||||
$user->update(['setup' => Setup::Subscription]);
|
||||
|
||||
$account = $user->account;
|
||||
$defaultPlan = Plan::where('slug', PlanSlug::Starter)->firstOrFail();
|
||||
|
||||
$workspace->createOrGetStripeCustomer([
|
||||
'email' => $workspace->stripeEmail(),
|
||||
'name' => $workspace->stripeName(),
|
||||
$account->createOrGetStripeCustomer([
|
||||
'email' => $account->stripeEmail(),
|
||||
'name' => $account->stripeName(),
|
||||
]);
|
||||
|
||||
$subscription = $workspace->newSubscription(Workspace::SUBSCRIPTION_NAME, $defaultPlan->stripe_monthly_price_id)
|
||||
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $defaultPlan->stripe_monthly_price_id)
|
||||
->allowPromotionCodes()
|
||||
->trialDays(config('cashier.trial_days'));
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
|
|||
'cancel_url' => route('app.onboarding.connect'),
|
||||
]);
|
||||
|
||||
$workspace->update(['plan_id' => $defaultPlan->id]);
|
||||
$account->update(['plan_id' => $defaultPlan->id]);
|
||||
|
||||
return Inertia::location($checkoutSession->url);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
namespace App\Http\Controllers\App\Settings;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\Settings\ProfileDeleteRequest;
|
||||
use App\Http\Requests\App\Settings\ProfileUpdateRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -93,14 +93,20 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
|||
DB::transaction(function () use ($user) {
|
||||
$user->update(['current_workspace_id' => null]);
|
||||
|
||||
$ownedWorkspaces = $user->workspaces()->wherePivot('role', Role::Owner->value)->get();
|
||||
$account = $user->account;
|
||||
|
||||
// Cancel account subscription if exists
|
||||
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
|
||||
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
|
||||
}
|
||||
|
||||
if ($account) {
|
||||
$account->subscriptions()->delete();
|
||||
}
|
||||
|
||||
$ownedWorkspaces = Workspace::where('user_id', $user->id)->get();
|
||||
|
||||
foreach ($ownedWorkspaces as $workspace) {
|
||||
if ($workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) {
|
||||
$workspace->subscription(Workspace::SUBSCRIPTION_NAME)->cancelNow();
|
||||
}
|
||||
$workspace->subscriptions()->delete();
|
||||
|
||||
foreach ($workspace->members as $member) {
|
||||
if ($member->id !== $user->id && $member->current_workspace_id === $workspace->id) {
|
||||
$otherWorkspace = $member->workspaces()
|
||||
|
|
@ -114,12 +120,15 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
|||
$workspace->socialAccounts()->delete();
|
||||
$workspace->hashtags()->delete();
|
||||
$workspace->labels()->delete();
|
||||
$workspace->invites()->delete();
|
||||
$workspace->members()->detach();
|
||||
$workspace->delete();
|
||||
}
|
||||
|
||||
$user->workspaces()->detach();
|
||||
|
||||
if ($account) {
|
||||
$account->delete();
|
||||
}
|
||||
});
|
||||
|
||||
Auth::logout();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
use App\Actions\Workspace\CreateWorkspace;
|
||||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Models\Workspace;
|
||||
|
|
@ -39,7 +38,7 @@ public function create(Request $request): Response|RedirectResponse
|
|||
|
||||
$workspace = $user->currentWorkspace;
|
||||
|
||||
if ($user->ownedWorkspacesCount() > 0 && (! $workspace || ! $workspace->hasActiveSubscription())) {
|
||||
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index')
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
|
@ -51,9 +50,7 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
$workspace = $user->currentWorkspace;
|
||||
|
||||
if ($user->ownedWorkspacesCount() > 0 && (! $workspace || ! $workspace->hasActiveSubscription())) {
|
||||
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index')
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
|
@ -99,11 +96,11 @@ public function settings(Request $request): Response|RedirectResponse
|
|||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
'role' => $member->pivot->role,
|
||||
'is_owner' => $member->pivot->role === Role::Owner->value,
|
||||
'is_owner' => $member->id === $workspace->account?->owner_id,
|
||||
]);
|
||||
|
||||
$invitations = $workspace->invites()
|
||||
->select('id', 'email', 'role')
|
||||
->select('id', 'email')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
use App\Actions\Invite\RemoveMember;
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Http\Requests\App\Invite\StoreWorkspaceInviteRequest;
|
||||
use App\Models\WorkspaceInvite;
|
||||
use App\Models\Invite;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -34,7 +34,6 @@ public function index(Request $request): Response|RedirectResponse
|
|||
->latest()
|
||||
->get(),
|
||||
'members' => $workspace->members()
|
||||
->wherePivot('role', '!=', WorkspaceRole::Owner->value)
|
||||
->get()
|
||||
->map(fn ($member) => [
|
||||
'id' => $member->id,
|
||||
|
|
@ -43,13 +42,11 @@ public function index(Request $request): Response|RedirectResponse
|
|||
'role' => $member->pivot->role,
|
||||
]),
|
||||
'owner' => [
|
||||
'id' => $workspace->owner->id,
|
||||
'name' => $workspace->owner->name,
|
||||
'email' => $workspace->owner->email,
|
||||
'role' => WorkspaceRole::Owner->value,
|
||||
'id' => $workspace->account?->owner?->id,
|
||||
'name' => $workspace->account?->owner?->name,
|
||||
'email' => $workspace->account?->owner?->email,
|
||||
],
|
||||
'roles' => collect(WorkspaceRole::cases())
|
||||
->filter(fn ($role) => $role !== WorkspaceRole::Owner)
|
||||
->map(fn ($role) => [
|
||||
'value' => $role->value,
|
||||
'label' => $role->label(),
|
||||
|
|
@ -91,7 +88,7 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
|
|||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse
|
||||
public function destroy(Request $request, Invite $invite): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
|
|
@ -101,7 +98,7 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp
|
|||
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
if ($invite->workspace_id !== $workspace->id) {
|
||||
if ($invite->account_id !== $workspace->account_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
|
@ -123,10 +120,9 @@ public function removeMember(Request $request, string $userId): RedirectResponse
|
|||
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
$memberPivot = $workspace->members()->where('user_id', $userId)->first()?->pivot;
|
||||
|
||||
if ($memberPivot && $memberPivot->role === WorkspaceRole::Owner->value) {
|
||||
return back()->withErrors(['member' => 'Cannot remove the workspace owner.']);
|
||||
// Account owner cannot be removed
|
||||
if ($userId === $workspace->account?->owner_id) {
|
||||
return back()->withErrors(['member' => 'Cannot remove the account owner.']);
|
||||
}
|
||||
|
||||
RemoveMember::execute($workspace, $userId);
|
||||
|
|
@ -147,14 +143,13 @@ public function updateRole(Request $request, string $userId): RedirectResponse
|
|||
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
$memberPivot = $workspace->members()->where('user_id', $userId)->first()?->pivot;
|
||||
|
||||
if ($memberPivot && $memberPivot->role === WorkspaceRole::Owner->value) {
|
||||
return back()->withErrors(['role' => 'Cannot change the workspace owner role.']);
|
||||
// Account owner's role cannot be changed
|
||||
if ($userId === $workspace->account?->owner_id) {
|
||||
return back()->withErrors(['role' => 'Cannot change the account owner role.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'role' => ['required', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])],
|
||||
'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
|
||||
]);
|
||||
|
||||
$workspace->members()->updateExistingPivot($userId, [
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WorkspaceInvite;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -16,22 +18,19 @@ class AcceptInviteController extends Controller
|
|||
/**
|
||||
* Display the invite view.
|
||||
*/
|
||||
public function show(WorkspaceInvite $invite): Response
|
||||
public function show(Invite $invite): Response
|
||||
{
|
||||
$invite->load('workspace');
|
||||
$invite->load('account');
|
||||
|
||||
return Inertia::render('auth/AcceptInvite', [
|
||||
'invite' => [
|
||||
'id' => $invite->id,
|
||||
'email' => $invite->email,
|
||||
'role' => [
|
||||
'value' => $invite->role->value,
|
||||
'label' => $invite->role->label(),
|
||||
],
|
||||
'workspace' => [
|
||||
'id' => $invite->workspace->id,
|
||||
'name' => $invite->workspace->name,
|
||||
'account' => [
|
||||
'id' => $invite->account->id,
|
||||
'name' => $invite->account->name,
|
||||
],
|
||||
'workspaces' => $invite->workspaces,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
@ -39,7 +38,7 @@ public function show(WorkspaceInvite $invite): Response
|
|||
/**
|
||||
* Accept the invite.
|
||||
*/
|
||||
public function accept(Request $request, WorkspaceInvite $invite): RedirectResponse
|
||||
public function accept(Request $request, Invite $invite): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
|
|
@ -51,9 +50,9 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo
|
|||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
if ($invite->workspace->hasMember($user)) {
|
||||
$invite->delete();
|
||||
// Check if already a member of the account
|
||||
if ($user->account_id === $invite->account_id) {
|
||||
$invite->update(['accepted_at' => now()]);
|
||||
|
||||
session()->flash('flash.banner', __('settings.members.flash.already_member'));
|
||||
session()->flash('flash.bannerStyle', 'info');
|
||||
|
|
@ -61,10 +60,28 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo
|
|||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
// Accept the invite
|
||||
$workspaceId = $invite->workspace_id;
|
||||
$invite->accept($user);
|
||||
$user->update(['current_workspace_id' => $workspaceId]);
|
||||
// Add user to the account
|
||||
$user->update(['account_id' => $invite->account_id]);
|
||||
|
||||
// Attach user to the invited workspaces
|
||||
if ($invite->workspaces) {
|
||||
foreach ($invite->workspaces as $workspaceId) {
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if ($workspace && $workspace->account_id === $invite->account_id) {
|
||||
$workspace->members()->syncWithoutDetaching([
|
||||
$user->id => ['role' => Role::Member->value],
|
||||
]);
|
||||
|
||||
// Set first workspace as current
|
||||
if (! $user->current_workspace_id) {
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invite->update(['accepted_at' => now()]);
|
||||
|
||||
session()->flash('flash.banner', __('settings.members.flash.invite_accepted'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
|
@ -75,7 +92,7 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo
|
|||
/**
|
||||
* Decline the invite.
|
||||
*/
|
||||
public function decline(Request $request, WorkspaceInvite $invite): RedirectResponse
|
||||
public function decline(Request $request, Invite $invite): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ protected function ensureSocialAccountLimit(Workspace $workspace): void
|
|||
return;
|
||||
}
|
||||
|
||||
$limit = Feature::for($workspace)->value(SocialAccountLimit::class);
|
||||
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
|
||||
|
||||
if ($workspace->socialAccounts()->count() >= $limit) {
|
||||
abort(SymfonyResponse::HTTP_FORBIDDEN, __('accounts.limit_reached'));
|
||||
|
|
@ -56,7 +56,6 @@ public function index(Request $request): Response|RedirectResponse
|
|||
$this->authorize('view', $workspace);
|
||||
|
||||
$connectedAccounts = $workspace->socialAccounts()
|
||||
->with('brand')
|
||||
->get();
|
||||
|
||||
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
|
||||
|
|
|
|||
|
|
@ -54,6 +54,6 @@ public function handle(Request $request, Closure $next): Response
|
|||
|
||||
private function hasActiveSubscription(Workspace $workspace): bool
|
||||
{
|
||||
return $workspace->hasActiveSubscription();
|
||||
return $workspace->account?->hasActiveSubscription() ?? false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ public function handle(Request $request, Closure $next): Response
|
|||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$workspace = $user->currentWorkspace;
|
||||
$account = $user->account;
|
||||
|
||||
if ($workspace && $workspace->hasActiveSubscription()) {
|
||||
if ($account && $account->hasActiveSubscription()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,6 @@ public function handle(Request $request, Closure $next): Response
|
|||
|
||||
private function hasActiveSubscription(Workspace $workspace): bool
|
||||
{
|
||||
return $workspace->hasActiveSubscription();
|
||||
return $workspace->account?->hasActiveSubscription() ?? false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public function rules(): array
|
|||
{
|
||||
return [
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['nullable', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])],
|
||||
'role' => ['nullable', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ public static function summary(Workspace $workspace): array
|
|||
|
||||
private static function resolveRole(Workspace $workspace, User $user): ?string
|
||||
{
|
||||
if ($user->isAccountOwner() && $workspace->account_id === $user->account_id) {
|
||||
return 'owner';
|
||||
}
|
||||
|
||||
return $workspace->members()
|
||||
->where('users.id', $user->id)
|
||||
->first()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ public function handle(WebhookReceived $event): void
|
|||
return;
|
||||
}
|
||||
|
||||
$workspace = Workspace::where('stripe_id', $stripeCustomerId)->first();
|
||||
$workspace = Account::where('stripe_id', $stripeCustomerId)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
return;
|
||||
|
|
@ -42,7 +42,7 @@ public function handle(WebhookReceived $event): void
|
|||
}
|
||||
}
|
||||
|
||||
protected function handleSubscriptionCreated(Workspace $workspace, array $payload): void
|
||||
protected function handleSubscriptionCreated(Account $workspace, array $payload): void
|
||||
{
|
||||
$owner = $workspace->owner;
|
||||
|
||||
|
|
@ -53,12 +53,12 @@ protected function handleSubscriptionCreated(Workspace $workspace, array $payloa
|
|||
SubscriptionCreated::dispatch($workspace);
|
||||
}
|
||||
|
||||
protected function handleSubscriptionUpdated(Workspace $workspace, array $payload): void
|
||||
protected function handleSubscriptionUpdated(Account $workspace, array $payload): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
protected function handleSubscriptionDeleted(Workspace $workspace, array $payload): void
|
||||
protected function handleSubscriptionDeleted(Account $workspace, array $payload): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\WorkspaceInvite as WorkspaceInviteModel;
|
||||
use App\Models\Invite;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
|
|
@ -17,13 +17,13 @@ class WorkspaceInvite extends Mailable implements ShouldQueue
|
|||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public WorkspaceInviteModel $invite
|
||||
public Invite $invite
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "You've been invited to join {$this->invite->workspace->name}",
|
||||
subject: "You've been invited to join {$this->invite->account->name}",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +32,8 @@ public function content(): Content
|
|||
return new Content(
|
||||
view: 'mail.workspace-invite',
|
||||
with: [
|
||||
'title' => "You've been invited to join {$this->invite->workspace->name}",
|
||||
'previewText' => "You've been invited to join {$this->invite->workspace->name}",
|
||||
'title' => "You've been invited to join {$this->invite->account->name}",
|
||||
'previewText' => "You've been invited to join {$this->invite->account->name}",
|
||||
'invite' => $this->invite,
|
||||
'url' => route('app.invites.show', $this->invite),
|
||||
],
|
||||
|
|
|
|||
76
app/Models/Account.php
Normal file
76
app/Models/Account.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\AccountFactory;
|
||||
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;
|
||||
use Laravel\Cashier\Billable;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
/** @use HasFactory<AccountFactory> */
|
||||
use Billable, HasFactory, HasUuids;
|
||||
|
||||
public const SUBSCRIPTION_NAME = 'default';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_id',
|
||||
'name',
|
||||
'plan_id',
|
||||
];
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_id');
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Plan::class);
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function workspaces(): HasMany
|
||||
{
|
||||
return $this->hasMany(Workspace::class);
|
||||
}
|
||||
|
||||
public function invites(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invite::class);
|
||||
}
|
||||
|
||||
public function hasActiveSubscription(): bool
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->subscribed(self::SUBSCRIPTION_NAME);
|
||||
}
|
||||
|
||||
public function isOnTrial(): bool
|
||||
{
|
||||
return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false;
|
||||
}
|
||||
|
||||
public function stripeEmail(): string
|
||||
{
|
||||
return $this->owner?->email ?? '';
|
||||
}
|
||||
|
||||
public function stripeName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
43
app/Models/Invite.php
Normal file
43
app/Models/Invite.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\InviteFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Invite extends Model
|
||||
{
|
||||
/** @use HasFactory<InviteFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'invited_by',
|
||||
'email',
|
||||
'workspaces',
|
||||
'accepted_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'workspaces' => 'array',
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ class Plan extends Model
|
|||
'yearly_price',
|
||||
'social_account_limit',
|
||||
'member_limit',
|
||||
'brand_limit',
|
||||
'workspace_limit',
|
||||
'ai_images_limit',
|
||||
'ai_videos_limit',
|
||||
'data_retention_days',
|
||||
|
|
@ -43,7 +43,7 @@ protected function casts(): array
|
|||
'yearly_price' => 'integer',
|
||||
'social_account_limit' => 'integer',
|
||||
'member_limit' => 'integer',
|
||||
'brand_limit' => 'integer',
|
||||
'workspace_limit' => 'integer',
|
||||
'ai_images_limit' => 'integer',
|
||||
'ai_videos_limit' => 'integer',
|
||||
'data_retention_days' => 'integer',
|
||||
|
|
@ -51,9 +51,9 @@ protected function casts(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function workspaces(): HasMany
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Workspace::class);
|
||||
return $this->hasMany(Account::class);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ class SocialAccount extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'brand_id',
|
||||
'platform',
|
||||
'platform_user_id',
|
||||
'username',
|
||||
|
|
@ -70,11 +69,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
|
@ -50,6 +49,6 @@ public function belongsToWorkspace(Workspace $workspace): bool
|
|||
*/
|
||||
public function ownedWorkspacesCount(): int
|
||||
{
|
||||
return $this->workspaces()->wherePivot('role', Role::Owner->value)->count();
|
||||
return Workspace::where('user_id', $this->id)->count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
|
|
@ -24,8 +25,6 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
use HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
|
|
@ -34,13 +33,12 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
'password',
|
||||
'setup',
|
||||
'persona',
|
||||
'account_id',
|
||||
'current_workspace_id',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
|
|
@ -65,11 +63,6 @@ public function getPhotoUrlAttribute(): ?string
|
|||
return $this->getFirstMediaUrl('avatar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -81,9 +74,6 @@ protected function casts(): array
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Notification, $this>
|
||||
*/
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class);
|
||||
|
|
@ -94,12 +84,22 @@ public function notificationPreference(): HasOne
|
|||
return $this->hasOne(NotificationPreference::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function isAccountOwner(): bool
|
||||
{
|
||||
return $this->id === $this->account?->owner_id;
|
||||
}
|
||||
|
||||
public function wantsEmailFor(NotificationType $type): bool
|
||||
{
|
||||
$preference = $this->notificationPreference;
|
||||
|
||||
if (! $preference) {
|
||||
return true; // Default: all enabled
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
|
|
|
|||
|
|
@ -6,28 +6,22 @@
|
|||
|
||||
use App\Models\Traits\HasMedia;
|
||||
use Database\Factories\WorkspaceFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
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\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Laravel\Cashier\Billable;
|
||||
|
||||
class Workspace extends Model
|
||||
{
|
||||
/** @use HasFactory<WorkspaceFactory> */
|
||||
use Billable, HasFactory, HasMedia, HasUuids;
|
||||
|
||||
public const SUBSCRIPTION_NAME = 'default';
|
||||
use HasFactory, HasMedia, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'user_id',
|
||||
'plan_id',
|
||||
'stripe_id',
|
||||
'pm_type',
|
||||
'pm_last_four',
|
||||
'trial_ends_at',
|
||||
'name',
|
||||
'timezone',
|
||||
];
|
||||
|
|
@ -44,16 +38,16 @@ public function getLogoUrlAttribute(): ?string
|
|||
return $this->getFirstMediaUrl('logo') ?: null;
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -71,11 +65,6 @@ public function posts(): HasMany
|
|||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function invites(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceInvite::class);
|
||||
}
|
||||
|
||||
public function hashtags(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceHashtag::class);
|
||||
|
|
@ -86,9 +75,16 @@ public function labels(): HasMany
|
|||
return $this->hasMany(WorkspaceLabel::class);
|
||||
}
|
||||
|
||||
public function brands(): HasMany
|
||||
/**
|
||||
* Get invites for this workspace (invites from the same account that include this workspace).
|
||||
*
|
||||
* @return Collection<int, Invite>
|
||||
*/
|
||||
public function invites()
|
||||
{
|
||||
return $this->hasMany(Brand::class);
|
||||
return Invite::where('account_id', $this->account_id)
|
||||
->whereJsonContains('workspaces', $this->id)
|
||||
->whereNull('accepted_at');
|
||||
}
|
||||
|
||||
public function apiTokens(): HasMany
|
||||
|
|
@ -98,7 +94,7 @@ public function apiTokens(): HasMany
|
|||
|
||||
public function hasMember(User $user): bool
|
||||
{
|
||||
return $this->user_id === $user->id || $this->members()->where('user_id', $user->id)->exists();
|
||||
return $this->account?->owner_id === $user->id || $this->members()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function hasConnectedPlatform(string $platform): bool
|
||||
|
|
@ -110,41 +106,4 @@ public function getSocialAccount(string $platform): ?SocialAccount
|
|||
{
|
||||
return $this->socialAccounts()->where('platform', $platform)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workspace has an active subscription.
|
||||
* In self-hosted mode, always returns true.
|
||||
*/
|
||||
public function hasActiveSubscription(): bool
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->subscribed(self::SUBSCRIPTION_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workspace is on a trial period.
|
||||
*/
|
||||
public function isOnTrial(): bool
|
||||
{
|
||||
return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the email address for Stripe.
|
||||
*/
|
||||
public function stripeEmail(): string
|
||||
{
|
||||
return $this->owner?->email ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name for Stripe.
|
||||
*/
|
||||
public function stripeName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Features\BrandLimit;
|
||||
use App\Models\Brand;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$limit = Feature::for($workspace)->value(BrandLimit::class);
|
||||
|
||||
return $workspace->brands()->count() < $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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,57 +19,56 @@ public function viewAny(User $user): bool
|
|||
|
||||
public function view(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isMember($user, $workspace);
|
||||
return $this->canAccess($user, $workspace);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
return $user->isAccountOwner();
|
||||
}
|
||||
|
||||
public function update(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
|
||||
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function delete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner]);
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
public function restore(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner]);
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner]);
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
public function manageTeam(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
|
||||
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function manageAccounts(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
|
||||
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function createPost(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isMember($user, $workspace);
|
||||
}
|
||||
if ($this->isOwner($user, $workspace)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function manageBilling(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->hasRole($user, $workspace, [Role::Owner]);
|
||||
return $this->hasRole($user, $workspace, [Role::Admin, Role::Member]);
|
||||
}
|
||||
|
||||
public function inviteMember(User $user, Workspace $workspace): bool
|
||||
{
|
||||
if (! $this->hasRole($user, $workspace, [Role::Owner, Role::Admin])) {
|
||||
if (! $this->isOwnerOrWorkspaceAdmin($user, $workspace)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -77,16 +76,49 @@ public function inviteMember(User $user, Workspace $workspace): bool
|
|||
return true;
|
||||
}
|
||||
|
||||
$limit = Feature::for($workspace)->value(MemberLimit::class);
|
||||
$limit = Feature::for($user->account)->value(MemberLimit::class);
|
||||
|
||||
return $workspace->members()->count() < $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
public function manageBilling(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
private function isOwner(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $workspace->account_id === $user->account_id && $user->isAccountOwner();
|
||||
}
|
||||
|
||||
private function isOwnerOrWorkspaceAdmin(User $user, Workspace $workspace): bool
|
||||
{
|
||||
if ($this->isOwner($user, $workspace)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasRole($user, $workspace, [Role::Admin]);
|
||||
}
|
||||
|
||||
private function canAccess(User $user, Workspace $workspace): bool
|
||||
{
|
||||
if ($workspace->account_id !== $user->account_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAccountOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $workspace->members()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
private function hasRole(User $user, Workspace $workspace, array $roles): bool
|
||||
{
|
||||
if ($workspace->account_id !== $user->account_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$member = $workspace->members()->where('user_id', $user->id)->first();
|
||||
|
||||
if (! $member) {
|
||||
|
|
@ -95,9 +127,4 @@ private function hasRole(User $user, Workspace $workspace, array $roles): bool
|
|||
|
||||
return in_array(Role::tryFrom($member->pivot->role), $roles);
|
||||
}
|
||||
|
||||
private function isMember(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $workspace->members()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\NotificationPreference;
|
||||
|
|
@ -18,7 +19,6 @@
|
|||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
use App\Models\WorkspaceInvite;
|
||||
use App\Models\WorkspaceLabel;
|
||||
use App\Socialite\InstagramProvider;
|
||||
use App\Socialite\LinkedInPageExtendSocialite;
|
||||
|
|
@ -76,11 +76,11 @@ public function boot(): void
|
|||
$this->configureSocialite();
|
||||
$this->configureStripeWebhooks();
|
||||
|
||||
Cashier::useCustomerModel(Workspace::class);
|
||||
Cashier::useCustomerModel(Account::class);
|
||||
Cashier::useSubscriptionModel(Subscription::class);
|
||||
Cashier::useSubscriptionItemModel(SubscriptionItem::class);
|
||||
|
||||
Feature::resolveScopeUsing(fn () => auth()->user()?->currentWorkspace);
|
||||
Feature::resolveScopeUsing(fn () => auth()->user()?->account);
|
||||
Feature::useMorphMap();
|
||||
Feature::discover();
|
||||
}
|
||||
|
|
@ -88,7 +88,8 @@ public function boot(): void
|
|||
protected function configureMorphMap(): void
|
||||
{
|
||||
Relation::enforceMorphMap([
|
||||
'brand' => Brand::class,
|
||||
'account' => Account::class,
|
||||
'invite' => Invite::class,
|
||||
'media' => Media::class,
|
||||
'notification' => Notification::class,
|
||||
'plan' => Plan::class,
|
||||
|
|
@ -101,7 +102,6 @@ protected function configureMorphMap(): void
|
|||
'user' => User::class,
|
||||
'workspace' => Workspace::class,
|
||||
'workspaceHashtag' => WorkspaceHashtag::class,
|
||||
'workspaceInvite' => WorkspaceInvite::class,
|
||||
'workspaceLabel' => WorkspaceLabel::class,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Brand>
|
||||
* @extends Factory<Account>
|
||||
*/
|
||||
class BrandFactory extends Factory
|
||||
class AccountFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
|
|
@ -21,7 +20,6 @@ class BrandFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => fake()->company(),
|
||||
];
|
||||
}
|
||||
31
database/factories/InviteFactory.php
Normal file
31
database/factories/InviteFactory.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Invite>
|
||||
*/
|
||||
class InviteFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'account_id' => Account::factory(),
|
||||
'invited_by' => User::factory(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'workspaces' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ public function definition(): array
|
|||
'yearly_price' => fake()->randomElement([9000, 19000, 39000, 79000]),
|
||||
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
|
||||
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'brand_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'ai_images_limit' => fake()->randomElement([10, 50, 200, 1000]),
|
||||
'ai_videos_limit' => fake()->randomElement([5, 25, 100, 500]),
|
||||
'data_retention_days' => fake()->randomElement([30, 90, 365, 730]),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
|
@ -32,6 +33,7 @@ public function definition(): array
|
|||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'account_id' => Account::factory(),
|
||||
'current_workspace_id' => null,
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
|
|
@ -39,6 +41,15 @@ public function definition(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) {
|
||||
if ($user->account_id && ! $user->account?->owner_id) {
|
||||
$user->account->update(['owner_id' => $user->id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
|
@ -22,9 +23,27 @@ public function definition(): array
|
|||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'plan_id' => null,
|
||||
'name' => fake()->company(),
|
||||
'timezone' => fake()->timezone(),
|
||||
];
|
||||
}
|
||||
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterMaking(function (Workspace $workspace) {
|
||||
if (! $workspace->account_id) {
|
||||
if ($workspace->user_id) {
|
||||
$user = User::find($workspace->user_id);
|
||||
|
||||
if ($user?->account_id) {
|
||||
$workspace->account_id = $user->account_id;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$workspace->account_id = Account::factory()->create()->id;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ public function up(): void
|
|||
$table->rememberToken();
|
||||
$table->string('setup')->nullable();
|
||||
$table->string('persona')->nullable();
|
||||
$table->uuid('account_id')->nullable();
|
||||
$table->uuid('current_workspace_id')->nullable();
|
||||
$table->uuid('language_id')->nullable();
|
||||
$table->timestamps();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public function up(): void
|
|||
$table->integer('yearly_price');
|
||||
$table->integer('social_account_limit');
|
||||
$table->integer('member_limit');
|
||||
$table->integer('brand_limit');
|
||||
$table->integer('workspace_limit');
|
||||
$table->integer('ai_images_limit');
|
||||
$table->integer('ai_videos_limit');
|
||||
$table->integer('data_retention_days');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<?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('accounts', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('owner_id')->nullable();
|
||||
$table->string('name');
|
||||
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('stripe_id')->nullable()->index();
|
||||
$table->string('pm_type')->nullable();
|
||||
$table->string('pm_last_four')->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('owner_id')->references('id')->on('users')->nullOnDelete();
|
||||
});
|
||||
|
||||
// Add FK constraint for users.account_id -> accounts.id
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreign('account_id')->references('id')->on('accounts')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('accounts');
|
||||
}
|
||||
};
|
||||
|
|
@ -15,14 +15,10 @@ public function up(): void
|
|||
{
|
||||
Schema::create('workspaces', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
|
||||
$table->uuid('user_id');
|
||||
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('timezone');
|
||||
$table->string('stripe_id')->nullable()->index();
|
||||
$table->string('pm_type')->nullable();
|
||||
$table->string('pm_last_four')->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
|
|
@ -15,7 +15,7 @@ public function up(): void
|
|||
{
|
||||
Schema::create('subscriptions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type');
|
||||
$table->string('stripe_id')->unique();
|
||||
$table->string('stripe_status');
|
||||
|
|
@ -25,7 +25,7 @@ public function up(): void
|
|||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'stripe_status']);
|
||||
$table->index(['account_id', 'stripe_status']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ public function up(): void
|
|||
Schema::create('social_accounts', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('workspace_id');
|
||||
$table->foreignUuid('brand_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('platform');
|
||||
$table->string('platform_user_id');
|
||||
$table->string('username')->nullable();
|
||||
|
|
|
|||
|
|
@ -8,27 +8,23 @@
|
|||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_invites', function (Blueprint $table) {
|
||||
Schema::create('invites', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('invited_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('email');
|
||||
$table->string('role')->default('member');
|
||||
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->json('workspaces');
|
||||
$table->timestamp('accepted_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['email', 'workspace_id']);
|
||||
$table->unique(['email', 'account_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workspace_invites');
|
||||
Schema::dropIfExists('invites');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public function run(): void
|
|||
'yearly_price' => 19000,
|
||||
'social_account_limit' => 5,
|
||||
'member_limit' => 1,
|
||||
'brand_limit' => 0,
|
||||
'workspace_limit' => 1,
|
||||
'ai_images_limit' => 50,
|
||||
'ai_videos_limit' => 10,
|
||||
'data_retention_days' => 30,
|
||||
|
|
@ -40,7 +40,7 @@ public function run(): void
|
|||
'yearly_price' => 29000,
|
||||
'social_account_limit' => 10,
|
||||
'member_limit' => 5,
|
||||
'brand_limit' => 5,
|
||||
'workspace_limit' => 5,
|
||||
'ai_images_limit' => 150,
|
||||
'ai_videos_limit' => 30,
|
||||
'data_retention_days' => 60,
|
||||
|
|
@ -55,7 +55,7 @@ public function run(): void
|
|||
'yearly_price' => 49000,
|
||||
'social_account_limit' => 30,
|
||||
'member_limit' => 15,
|
||||
'brand_limit' => 15,
|
||||
'workspace_limit' => 15,
|
||||
'ai_images_limit' => 500,
|
||||
'ai_videos_limit' => 100,
|
||||
'data_retention_days' => 90,
|
||||
|
|
@ -70,7 +70,7 @@ public function run(): void
|
|||
'yearly_price' => 99000,
|
||||
'social_account_limit' => 100,
|
||||
'member_limit' => 20,
|
||||
'brand_limit' => 50,
|
||||
'workspace_limit' => 50,
|
||||
'ai_images_limit' => 2000,
|
||||
'ai_videos_limit' => 500,
|
||||
'data_retention_days' => 730,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,35 @@
|
|||
# Workspace Billing, Plans & Brands
|
||||
# Account Billing, Plans & Workspaces
|
||||
|
||||
## Overview
|
||||
|
||||
Move billing from User to Workspace. Each workspace has its own subscription tied to a plan. Introduce a `plans` table with 4 tiers (Starter, Plus, Pro, Max) that enforce limits on social accounts, members, brands, AI generation, and data retention. Introduce `brands` as a way to group social accounts within a workspace.
|
||||
Introduce an Account entity as the central billing and organizational unit. Account holds the Stripe subscription, plan, and limits. Workspaces group social accounts within an Account. Users belong to one Account with an account-level role (admin/user) and can be assigned to specific workspaces with workspace-level roles (member/viewer). The Account owner has full access to everything.
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
Account (Billable, plan_id, owner_id)
|
||||
├── Users (account_id, account_role: admin/user)
|
||||
└── Workspaces
|
||||
├── Members (user_id, workspace_role: member/viewer)
|
||||
├── Invites
|
||||
└── Social Accounts
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
### Account-level
|
||||
|
||||
Owner is determined by `accounts.owner_id`. No account-level role column on users. Only the owner can manage billing, create workspaces, and delete the account. The owner has full access to all workspaces automatically.
|
||||
|
||||
### Workspace-level roles (`user_workspace.role`)
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| Admin | Everything in the workspace: manage members, connect accounts, settings |
|
||||
| Member | Create posts, schedule |
|
||||
| Viewer | View only (future: comments) |
|
||||
|
||||
The Account owner has full access to all workspaces without needing a workspace pivot record.
|
||||
|
||||
## Plans
|
||||
|
||||
|
|
@ -10,116 +37,151 @@ ## Plans
|
|||
|---|---|---|---|---|
|
||||
| Social accounts | 5 | 10 | 30 | 100 |
|
||||
| Members | 1 | 5 | 15 | 20 |
|
||||
| Brands | 0 | 5 | 15 | 50 |
|
||||
| Workspaces | 1 | 5 | 15 | 50 |
|
||||
| AI Images/month | 50 | 150 | 500 | 2000 |
|
||||
| AI Videos/month | 10 | 30 | 100 | 500 |
|
||||
| Data retention (days) | 30 | 60 | 90 | 730 |
|
||||
| Monthly price | $19 | $29 | $49 | $99 |
|
||||
| Yearly price | $190 | $290 | $490 | $990 |
|
||||
|
||||
All limits are at the **Account level** (totals across all workspaces).
|
||||
|
||||
Starter plan: 1 member (owner only), 1 workspace (default, UI hides workspace management).
|
||||
|
||||
Trial period: 8 days (configurable via `CASHIER_TRIAL_DAYS`).
|
||||
|
||||
---
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New table: `plans`
|
||||
### New table: `accounts`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid, PK | |
|
||||
| slug | string, unique | "starter", "plus", "pro", "max" |
|
||||
| name | string | "Starter", "Plus", "Pro", "Max" |
|
||||
| stripe_monthly_price_id | string, nullable | Stripe price ID for monthly billing |
|
||||
| stripe_yearly_price_id | string, nullable | Stripe price ID for yearly billing |
|
||||
| monthly_price | integer | In cents (1900, 2900, 4900, 9900) |
|
||||
| yearly_price | integer | In cents (19000, 29000, 49000, 99000) |
|
||||
| social_account_limit | integer | 5, 10, 30, 100 |
|
||||
| member_limit | integer | 1, 5, 15, 20 |
|
||||
| brand_limit | integer | 0, 5, 15, 50 |
|
||||
| ai_images_limit | integer | 50, 150, 500, 2000 |
|
||||
| ai_videos_limit | integer | 10, 30, 100, 500 |
|
||||
| data_retention_days | integer | 30, 60, 90, 730 |
|
||||
| sort | integer | Display order |
|
||||
| is_archived | boolean, default false | Hide from selection without deleting |
|
||||
| timestamps | | |
|
||||
|
||||
### New table: `brands`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid, PK | |
|
||||
| workspace_id | FK -> workspaces, cascade delete | |
|
||||
| name | string | |
|
||||
| timestamps | | |
|
||||
|
||||
### Modify table: `social_accounts`
|
||||
|
||||
Add `brand_id` (FK -> brands, nullable, set null on delete). Social accounts can optionally belong to a brand for grouping.
|
||||
|
||||
### Modify table: `workspaces`
|
||||
|
||||
Add columns (Cashier Billable fields + plan reference):
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| plan_id | FK -> plans, nullable, constrained | Current plan |
|
||||
| owner_id | FK -> users, nullable initially | Set after user creation (chicken-egg) |
|
||||
| plan_id | FK -> plans, nullable | Current plan |
|
||||
| name | string | Account/company name |
|
||||
| stripe_id | string, nullable, indexed | Stripe customer ID |
|
||||
| pm_type | string, nullable | Payment method type |
|
||||
| pm_last_four | string, nullable | Last 4 digits |
|
||||
| trial_ends_at | timestamp, nullable | |
|
||||
| timestamps | | |
|
||||
|
||||
### Modify table: `subscriptions`
|
||||
Note: `owner_id` is nullable because during signup the user and account are created in the same transaction. Set `owner_id` after user creation.
|
||||
|
||||
Change FK from `user_id` to `workspace_id` (uuid, FK -> workspaces, cascade delete). Drop the old `user_id` column.
|
||||
### New table: `plans` (unchanged from current)
|
||||
|
||||
### Modify table: `subscription_items`
|
||||
|
||||
No changes needed (references `subscription_id` which remains the same).
|
||||
Already implemented. slug, name, stripe price IDs, all limits, sort, is_archived.
|
||||
|
||||
### Modify table: `users`
|
||||
|
||||
Remove billing columns: `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`.
|
||||
Add columns:
|
||||
- `account_id` (FK -> accounts, nullable, constrained)
|
||||
- `account_role` (string, default 'user') — enum: admin, user
|
||||
|
||||
Remove columns:
|
||||
- `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at` (already removed)
|
||||
|
||||
### Modify table: `workspaces`
|
||||
|
||||
Remove columns:
|
||||
- `plan_id` (moves to Account)
|
||||
- `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at` (moves to Account)
|
||||
|
||||
Add columns:
|
||||
- `account_id` (FK -> accounts, constrained, cascade delete)
|
||||
|
||||
Keep: `user_id` (original creator, not necessarily the owner), `name`, `timezone`
|
||||
|
||||
### Modify table: `user_workspace`
|
||||
|
||||
Change `role` values from `owner/admin/member` to `member/viewer`. Owner is determined by Account, not workspace pivot.
|
||||
|
||||
### Modify table: `subscriptions`
|
||||
|
||||
Change FK from `workspace_id` to `account_id`.
|
||||
|
||||
### Modify table: `invites` (rename from `workspace_invites`)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid, PK | |
|
||||
| account_id | FK -> accounts, cascade delete | |
|
||||
| email | string | Invited email |
|
||||
| workspaces | json | Array of {workspace_id, role} |
|
||||
| invited_by | FK -> users, nullable | Who sent the invite |
|
||||
| accepted_at | timestamp, nullable | |
|
||||
| timestamps | | |
|
||||
|
||||
### Remove table: `brands`
|
||||
|
||||
Brands concept is replaced by Workspaces. Remove brands table, remove `brand_id` from social_accounts.
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
### Plan (new)
|
||||
### Account (new)
|
||||
|
||||
- UUID primary key
|
||||
- Fillable: slug, name, prices, all limits, sort, is_archived
|
||||
- Casts: is_archived (boolean), monthly_price (integer), yearly_price (integer)
|
||||
- Scopes: `active()` (where is_archived = false)
|
||||
- Relationship: `workspaces()` hasMany
|
||||
- Method: `formattedMonthlyPrice()`, `formattedYearlyPrice()`
|
||||
|
||||
### Brand (new)
|
||||
|
||||
- UUID primary key
|
||||
- Fillable: workspace_id, name
|
||||
- Relationship: `workspace()` belongsTo, `socialAccounts()` hasMany
|
||||
|
||||
### Workspace (modified)
|
||||
|
||||
- Add `Billable` trait from Laravel Cashier
|
||||
- Add to fillable: `plan_id`, `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`
|
||||
- New relationships: `plan()` belongsTo, `brands()` hasMany
|
||||
- New constant: `SUBSCRIPTION_NAME = 'default'`
|
||||
- Methods: `hasActiveSubscription()`, `isOnTrial()`, `stripeEmail()` (returns owner's email), `stripeName()` (returns workspace name)
|
||||
- `Billable` trait from Cashier
|
||||
- Constant: `SUBSCRIPTION_NAME = 'default'`
|
||||
- Fillable: name, owner_id, plan_id
|
||||
- Relationships: `owner()` belongsTo User, `plan()` belongsTo Plan, `users()` hasMany User, `workspaces()` hasMany Workspace, `invites()` hasMany Invite
|
||||
- Methods: `hasActiveSubscription()`, `isOnTrial()`, `stripeEmail()` (owner email), `stripeName()` (account name)
|
||||
|
||||
### User (modified)
|
||||
|
||||
- Remove `Billable` trait (already removed)
|
||||
- Add `account_id` and `account_role` to fillable
|
||||
- Add cast: `account_role` to `AccountRole` enum
|
||||
- Add relationship: `account()` belongsTo Account
|
||||
- Remove: `HasWorkspace` trait methods related to billing
|
||||
- Keep: `currentWorkspace()`, `switchWorkspace()`, workspace navigation methods
|
||||
- Add: `isAccountOwner()` — `$this->id === $this->account?->owner_id`
|
||||
- Add: `isAccountAdmin()` — `$this->account_role === AccountRole::Admin || $this->isAccountOwner()`
|
||||
|
||||
### Workspace (modified)
|
||||
|
||||
- Remove `Billable` trait
|
||||
- Remove `SUBSCRIPTION_NAME` constant
|
||||
- Remove `hasActiveSubscription()`, `hasEverSubscribed()` methods
|
||||
- Remove billing fields from fillable
|
||||
- Simplify `HasWorkspace` trait: remove `canCreateWorkspace()`, `incrementWorkspaceQuantity()`, `decrementWorkspaceQuantity()`, `syncWorkspaceQuantity()`
|
||||
- Remove billing fields from fillable (stripe_id, pm_type, pm_last_four, trial_ends_at, plan_id)
|
||||
- Add `account_id` to fillable
|
||||
- Add relationship: `account()` belongsTo Account
|
||||
- Keep: members(), socialAccounts(), posts(), invites is now on Account
|
||||
|
||||
### SocialAccount (modified)
|
||||
### Invite (modified from WorkspaceInvite)
|
||||
|
||||
- Add `brand_id` to fillable
|
||||
- New relationship: `brand()` belongsTo (nullable)
|
||||
- Rename model from WorkspaceInvite to Invite
|
||||
- Add: `account_id`, `workspaces` (json), `invited_by`
|
||||
- Add cast: `workspaces` to array
|
||||
- Relationship: `account()` belongsTo Account, `invitedBy()` belongsTo User
|
||||
|
||||
---
|
||||
|
||||
## Enums
|
||||
|
||||
### AccountRole (new)
|
||||
|
||||
```php
|
||||
enum AccountRole: string
|
||||
{
|
||||
case Admin = 'admin';
|
||||
case User = 'user';
|
||||
}
|
||||
```
|
||||
|
||||
### WorkspaceRole (modify existing)
|
||||
|
||||
```php
|
||||
enum WorkspaceRole: string
|
||||
{
|
||||
case Member = 'member';
|
||||
case Viewer = 'viewer';
|
||||
}
|
||||
```
|
||||
|
||||
Remove `Owner` and `Admin` cases.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -128,303 +190,133 @@ ## Cashier Configuration
|
|||
### AppServiceProvider
|
||||
|
||||
```php
|
||||
Cashier::useCustomerModel(Workspace::class);
|
||||
Cashier::useCustomerModel(Account::class);
|
||||
```
|
||||
|
||||
### config/cashier.php
|
||||
|
||||
Remove the `plans` section (plans come from the database now). Keep Stripe keys, webhook, currency, trial_days, invoice settings.
|
||||
|
||||
---
|
||||
|
||||
## Middleware: EnsureSubscribed
|
||||
|
||||
Simplified logic:
|
||||
|
||||
1. If `config('trypost.self_hosted')` is true -> pass through
|
||||
2. Get `$user->currentWorkspace`
|
||||
3. If workspace has active subscription or is on trial -> pass through
|
||||
1. If `config('trypost.self_hosted')` → pass
|
||||
2. Get `$user->account`
|
||||
3. If account has active subscription or trial → pass
|
||||
4. Redirect to `/subscribe`
|
||||
|
||||
No more checking the workspace owner's subscription — the workspace IS the subscriber.
|
||||
---
|
||||
|
||||
## Pennant Features
|
||||
|
||||
Scope changes from Workspace to Account:
|
||||
|
||||
```php
|
||||
Feature::resolveScopeUsing(fn () => auth()->user()?->account);
|
||||
```
|
||||
|
||||
All 6 feature classes receive `Account $scope` instead of `Workspace $scope`:
|
||||
- `SocialAccountLimit` — `$scope->plan?->social_account_limit ?? 5`
|
||||
- `MemberLimit` — `$scope->plan?->member_limit ?? 1`
|
||||
- `WorkspaceLimit` — `$scope->plan?->workspace_limit ?? 1` (replaces BrandLimit)
|
||||
- `AiImagesLimit` — `$scope->plan?->ai_images_limit ?? 50`
|
||||
- `AiVideosLimit` — `$scope->plan?->ai_videos_limit ?? 10`
|
||||
- `DataRetentionDays` — `$scope->plan?->data_retention_days ?? 30`
|
||||
|
||||
---
|
||||
|
||||
## Limit Enforcement
|
||||
|
||||
All limits checked at Account level:
|
||||
|
||||
- **Social accounts**: total across all workspaces in the account
|
||||
- **Members**: unique users in the account (excluding owner) — `$account->users()->where('id', '!=', $account->owner_id)->count()`
|
||||
- **Workspaces**: `$account->workspaces()->count()`
|
||||
- **AI images/videos**: monthly usage tracked per account
|
||||
- **Data retention**: applied per account
|
||||
|
||||
---
|
||||
|
||||
## Controllers
|
||||
|
||||
### BillingController (modified)
|
||||
### BillingController
|
||||
|
||||
All billing operations change from `$user` to `$workspace`:
|
||||
All operations on `$user->account`:
|
||||
- `$account->subscribed()`, `$account->subscription()`, `$account->invoices()`
|
||||
|
||||
- `subscribe()` — show plan selection page with all active plans
|
||||
- `checkout(Plan $plan)` — create Stripe Checkout for workspace with selected plan's price ID
|
||||
- `processing()` — check `$workspace->subscribed()` instead of `$user->subscribed()`
|
||||
- `index()` — show workspace subscription, invoices, payment method, current plan details with limits and usage
|
||||
- `portal()` — `$workspace->redirectToBillingPortal()`
|
||||
- `swap(Plan $plan)` — swap workspace subscription to a different plan
|
||||
### OnboardingController
|
||||
|
||||
### OnboardingController (modified)
|
||||
Signup flow:
|
||||
1. Create Account
|
||||
2. Create User with `account_id` and `account_role = admin` (owner)
|
||||
3. Set `account.owner_id`
|
||||
4. Create default Workspace with `account_id`
|
||||
5. Stripe checkout on Account
|
||||
|
||||
`storeConnect()` changes from `$user->newSubscription(...)` to `$workspace->newSubscription(...)`. The plan selection needs to happen during onboarding — default to Starter plan or let user choose.
|
||||
### StripeEventListener
|
||||
|
||||
### StripeEventListener (modified)
|
||||
Find `Account::where('stripe_id', $stripeCustomerId)` instead of Workspace.
|
||||
|
||||
Change `User::where('stripe_id', ...)` to `Workspace::where('stripe_id', ...)`. Update `handleSubscriptionCreated` to work with workspace context (still updates user setup to Completed).
|
||||
### InviteController (replaces WorkspaceInviteController)
|
||||
|
||||
### BrandController (new)
|
||||
|
||||
Full CRUD:
|
||||
|
||||
- `index()` — list brands for current workspace with social account counts
|
||||
- `store(StoreBrandRequest)` — create brand (enforce plan limit via policy)
|
||||
- `update(UpdateBrandRequest, Brand)` — rename brand
|
||||
- `destroy(Brand)` — delete brand (social accounts get `brand_id = null`, not deleted)
|
||||
|
||||
### SocialAccountController (modified if exists)
|
||||
|
||||
Add ability to assign/unassign a social account to a brand.
|
||||
- `store()`: create invite on Account with workspaces + roles array
|
||||
- On accept: set user's `account_id`, create workspace pivot records
|
||||
|
||||
---
|
||||
|
||||
## Policies
|
||||
## Signup Flow
|
||||
|
||||
### BrandPolicy
|
||||
1. User registers (name, email, password)
|
||||
2. `CreateUser` action:
|
||||
- Creates Account (name = user's name)
|
||||
- Creates User with `account_id`, `account_role = admin`
|
||||
- Sets `account.owner_id = user.id`
|
||||
- Creates default Workspace within Account
|
||||
- Sets `user.current_workspace_id`
|
||||
3. Onboarding: role → connect accounts → Stripe checkout (on Account)
|
||||
|
||||
- `viewAny(User, Workspace)` — user belongs to workspace
|
||||
- `create(User, Workspace)` — user can manage accounts AND `$workspace->brands()->count() < $workspace->plan->brand_limit`
|
||||
- `update(User, Workspace, Brand)` — user can manage accounts AND brand belongs to workspace
|
||||
- `delete(User, Workspace, Brand)` — same as update
|
||||
## Invite Flow
|
||||
|
||||
### WorkspacePolicy (modified)
|
||||
|
||||
- `manageBilling(User, Workspace)` — only owner (unchanged)
|
||||
- `inviteMember(User, Workspace)` — enforce member limit: `$workspace->members()->count() < $workspace->plan->member_limit`
|
||||
|
||||
### SocialAccountPolicy (new or modified)
|
||||
|
||||
- Enforce social account limit on connect: `$workspace->socialAccounts()->count() < $workspace->plan->social_account_limit`
|
||||
1. Owner/Admin creates invite: email + [{workspace_id, role}]
|
||||
2. Email sent with invite link
|
||||
3. Person clicks link:
|
||||
- If no TryPost account: register with `account_id` pre-set, `account_role = user`
|
||||
- If has TryPost account with same Account: add workspace assignments
|
||||
- If has TryPost account with different Account: error — must use different email
|
||||
4. Workspace pivot records created per the invite's workspaces array
|
||||
|
||||
---
|
||||
|
||||
## Pennant Features (app/Features/)
|
||||
## UI Behavior by Plan
|
||||
|
||||
Use Laravel Pennant to resolve plan limits per workspace. Each feature class resolves the limit value from the workspace's plan, with a sensible fallback for self-hosted mode (unlimited).
|
||||
### Starter (1 workspace, 1 member)
|
||||
- Workspace switcher hidden
|
||||
- "Create workspace" hidden
|
||||
- Invite members hidden
|
||||
- Simple single-workspace experience
|
||||
|
||||
### SocialAccountLimit
|
||||
|
||||
Returns `$workspace->plan->social_account_limit` (fallback: `PHP_INT_MAX` for self-hosted).
|
||||
|
||||
### MemberLimit
|
||||
|
||||
Returns `$workspace->plan->member_limit` (fallback: `PHP_INT_MAX`).
|
||||
|
||||
### BrandLimit
|
||||
|
||||
Returns `$workspace->plan->brand_limit` (fallback: `PHP_INT_MAX`).
|
||||
|
||||
### AiImagesLimit
|
||||
|
||||
Returns `$workspace->plan->ai_images_limit` (fallback: `PHP_INT_MAX`).
|
||||
|
||||
### AiVideosLimit
|
||||
|
||||
Returns `$workspace->plan->ai_videos_limit` (fallback: `PHP_INT_MAX`).
|
||||
|
||||
### DataRetentionDays
|
||||
|
||||
Returns `$workspace->plan->data_retention_days` (fallback: `PHP_INT_MAX` for unlimited).
|
||||
|
||||
### Pennant Configuration
|
||||
|
||||
In `AppServiceProvider`:
|
||||
|
||||
```php
|
||||
Feature::resolveScopeUsing(fn () => auth()->user()?->currentWorkspace);
|
||||
Feature::discover();
|
||||
```
|
||||
|
||||
### Usage in Policies
|
||||
|
||||
Policies use Pennant to resolve limits:
|
||||
|
||||
```php
|
||||
use Laravel\Pennant\Feature;
|
||||
use App\Features\BrandLimit;
|
||||
|
||||
// In BrandPolicy::create()
|
||||
$limit = Feature::for($workspace)->value(BrandLimit::class);
|
||||
return $workspace->brands()->count() < $limit;
|
||||
```
|
||||
|
||||
This decouples limit enforcement from direct plan access, making it testable and overridable per workspace if needed.
|
||||
### Plus and above
|
||||
- Workspace switcher visible
|
||||
- Create workspace button visible
|
||||
- Invite members visible
|
||||
- Full multi-workspace experience
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
## Plans table: workspace_limit replaces brand_limit
|
||||
|
||||
### Billing routes (modified)
|
||||
|
||||
```
|
||||
GET /subscribe BillingController@subscribe
|
||||
POST /billing/checkout/{plan} BillingController@checkout
|
||||
GET /billing/processing BillingController@processing
|
||||
GET /settings/billing BillingController@index
|
||||
GET /settings/billing/portal BillingController@portal
|
||||
POST /settings/billing/swap/{plan} BillingController@swap
|
||||
```
|
||||
|
||||
### Brand routes (new)
|
||||
|
||||
```
|
||||
GET /brands BrandController@index
|
||||
POST /brands BrandController@store
|
||||
PUT /brands/{brand} BrandController@update
|
||||
DELETE /brands/{brand} BrandController@destroy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend (Vue)
|
||||
|
||||
### Subscribe page (modified)
|
||||
|
||||
Show plan selection cards (4 plans) with monthly/yearly toggle instead of a single checkout button. Each card shows limits and price. User selects plan -> goes to Stripe Checkout.
|
||||
|
||||
### Billing settings page (modified)
|
||||
|
||||
Show current plan name, limits with usage bars (e.g., "3/5 social accounts"), subscription status, invoices, payment method. Add "Change Plan" button that shows plan comparison.
|
||||
|
||||
### Brands pages (new)
|
||||
|
||||
- **Brands list** — cards/list showing brands with social account count per brand. Create button (disabled if at limit with tooltip explaining).
|
||||
- **Brand create/edit** — simple form with name field.
|
||||
- **Social account assignment** — in the accounts page, ability to assign accounts to brands (dropdown or drag).
|
||||
|
||||
### Sidebar (modified)
|
||||
|
||||
Add "Brands" link in the sidebar navigation (only visible if plan allows brands, i.e., brand_limit > 0).
|
||||
|
||||
---
|
||||
|
||||
## Seeder: PlanSeeder
|
||||
|
||||
Creates the 4 plans:
|
||||
|
||||
```php
|
||||
[
|
||||
[
|
||||
'slug' => 'starter',
|
||||
'name' => 'Starter',
|
||||
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
|
||||
'monthly_price' => 1900,
|
||||
'yearly_price' => 19000,
|
||||
'social_account_limit' => 5,
|
||||
'member_limit' => 1,
|
||||
'brand_limit' => 0,
|
||||
'ai_images_limit' => 50,
|
||||
'ai_videos_limit' => 10,
|
||||
'data_retention_days' => 30,
|
||||
'sort' => 1,
|
||||
],
|
||||
[
|
||||
'slug' => 'plus',
|
||||
'name' => 'Plus',
|
||||
'monthly_price' => 2900,
|
||||
'yearly_price' => 29000,
|
||||
'social_account_limit' => 10,
|
||||
'member_limit' => 5,
|
||||
'brand_limit' => 5,
|
||||
'ai_images_limit' => 150,
|
||||
'ai_videos_limit' => 30,
|
||||
'data_retention_days' => 60,
|
||||
'sort' => 2,
|
||||
],
|
||||
[
|
||||
'slug' => 'pro',
|
||||
'name' => 'Pro',
|
||||
'monthly_price' => 4900,
|
||||
'yearly_price' => 49000,
|
||||
'social_account_limit' => 30,
|
||||
'member_limit' => 15,
|
||||
'brand_limit' => 15,
|
||||
'ai_images_limit' => 500,
|
||||
'ai_videos_limit' => 100,
|
||||
'data_retention_days' => 90,
|
||||
'sort' => 3,
|
||||
],
|
||||
[
|
||||
'slug' => 'max',
|
||||
'name' => 'Max',
|
||||
'monthly_price' => 9900,
|
||||
'yearly_price' => 99000,
|
||||
'social_account_limit' => 100,
|
||||
'member_limit' => 20,
|
||||
'brand_limit' => 50,
|
||||
'ai_images_limit' => 2000,
|
||||
'ai_videos_limit' => 500,
|
||||
'data_retention_days' => 730,
|
||||
'sort' => 4,
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Plan tests
|
||||
- Plan seeder creates 4 plans with correct limits
|
||||
- Plan `active()` scope excludes archived plans
|
||||
- Plan formatted prices return correct values
|
||||
|
||||
### Brand CRUD tests
|
||||
- Create brand (success + limit enforcement)
|
||||
- List brands for workspace
|
||||
- Update brand name
|
||||
- Delete brand (social accounts get null brand_id)
|
||||
- Cannot create brand on Starter plan (limit = 0)
|
||||
- Cannot create brand beyond plan limit
|
||||
- Cannot access brands from another workspace
|
||||
|
||||
### Billing tests
|
||||
- Workspace checkout creates Stripe session
|
||||
- Workspace subscription grants access via middleware
|
||||
- No subscription redirects to /subscribe
|
||||
- Self-hosted mode bypasses subscription check
|
||||
- Plan swap updates workspace plan_id
|
||||
- Invoices returned for workspace
|
||||
- Only owner can manage billing
|
||||
|
||||
### Limit enforcement tests
|
||||
- Cannot connect social account beyond plan limit
|
||||
- Cannot invite member beyond plan limit
|
||||
- Cannot create brand beyond plan limit
|
||||
|
||||
### Migration tests
|
||||
- Workspace has billing columns after migration
|
||||
- User no longer has billing columns
|
||||
- Subscriptions reference workspace_id
|
||||
| Column change | Old | New |
|
||||
|---|---|---|
|
||||
| brand_limit | 0, 5, 15, 50 | renamed to workspace_limit: 1, 5, 15, 50 |
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted mode
|
||||
|
||||
All plan limits and subscription checks are bypassed when `config('trypost.self_hosted')` is true. In self-hosted mode:
|
||||
|
||||
- No plans table interaction needed
|
||||
- No subscription checks
|
||||
- Unlimited social accounts, members, brands
|
||||
- Unlimited data retention
|
||||
- Brand feature is still available (just no limit)
|
||||
All limits bypassed. Account still exists but no billing. Unlimited workspaces, members, social accounts.
|
||||
|
||||
---
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- Onboarding flow steps (Role -> Connect -> Payment -> Complete)
|
||||
- Social account OAuth flows
|
||||
- Social account OAuth flows (connect/disconnect)
|
||||
- Post creation and publishing
|
||||
- Analytics
|
||||
- Workspace creation flow
|
||||
- User authentication
|
||||
- Multi-account social connections (multiple accounts per platform)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
'description' => 'Overview of all your connected social accounts',
|
||||
'add_social' => 'Add Social',
|
||||
'add_social_title' => 'Connect a Social Account',
|
||||
'add_social_description' => 'Choose a platform to connect',
|
||||
'add_social_description' => 'Connect a social account to TryPost to start posting',
|
||||
'no_accounts' => 'No accounts connected yet',
|
||||
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
|
||||
'added' => 'Added :date',
|
||||
|
|
@ -20,10 +20,19 @@
|
|||
'view_profile' => 'View profile',
|
||||
'disconnect' => 'Disconnect',
|
||||
|
||||
'tooltips' => [
|
||||
'instagram_facebook' => 'Connects via your Facebook Page. Recommended for business accounts linked to a Facebook Page.',
|
||||
'instagram_direct' => 'Connects directly through Instagram. For professional/creator accounts without a Facebook Page.',
|
||||
'bluesky' => "We don't currently support two-factor authentication. If it's enabled on Bluesky, you'll need to disable it.",
|
||||
'descriptions' => [
|
||||
'linkedin' => 'Connect your LinkedIn personal profile',
|
||||
'linkedin-page' => 'Connect a LinkedIn company page',
|
||||
'x' => 'Connect your X (Twitter) account',
|
||||
'tiktok' => 'Connect your TikTok account',
|
||||
'youtube' => 'Connect a YouTube channel',
|
||||
'facebook' => 'Connect a Facebook page',
|
||||
'instagram' => 'Connect an Instagram professional account',
|
||||
'instagram-facebook' => 'Connect Instagram via Facebook page',
|
||||
'threads' => 'Connect your Threads account',
|
||||
'pinterest' => 'Connect your Pinterest account',
|
||||
'bluesky' => 'Connect your Bluesky account',
|
||||
'mastodon' => 'Connect your Mastodon account',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
'description' => 'Resumen de todas tus cuentas sociales conectadas',
|
||||
'add_social' => 'Agregar Red Social',
|
||||
'add_social_title' => 'Conectar una Cuenta Social',
|
||||
'add_social_description' => 'Elige una plataforma para conectar',
|
||||
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
|
||||
'no_accounts' => 'No hay cuentas conectadas todavía',
|
||||
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
|
||||
'added' => 'Agregada :date',
|
||||
|
|
@ -20,10 +20,19 @@
|
|||
'view_profile' => 'Ver perfil',
|
||||
'disconnect' => 'Desconectar',
|
||||
|
||||
'tooltips' => [
|
||||
'instagram_facebook' => 'Conecta a través de tu Página de Facebook. Recomendado para cuentas business vinculadas a una Página de Facebook.',
|
||||
'instagram_direct' => 'Conecta directamente por Instagram. Para cuentas profesionales/creadores sin Página de Facebook.',
|
||||
'bluesky' => 'No soportamos autenticación de dos factores. Si está activada en Bluesky, necesitarás desactivarla.',
|
||||
'descriptions' => [
|
||||
'linkedin' => 'Conecta tu perfil personal de LinkedIn',
|
||||
'linkedin-page' => 'Conecta una página de empresa de LinkedIn',
|
||||
'x' => 'Conecta tu cuenta de X (Twitter)',
|
||||
'tiktok' => 'Conecta tu cuenta de TikTok',
|
||||
'youtube' => 'Conecta un canal de YouTube',
|
||||
'facebook' => 'Conecta una página de Facebook',
|
||||
'instagram' => 'Conecta una cuenta profesional de Instagram',
|
||||
'instagram-facebook' => 'Conecta Instagram vía página de Facebook',
|
||||
'threads' => 'Conecta tu cuenta de Threads',
|
||||
'pinterest' => 'Conecta tu cuenta de Pinterest',
|
||||
'bluesky' => 'Conecta tu cuenta de Bluesky',
|
||||
'mastodon' => 'Conecta tu cuenta de Mastodon',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
|
|
|
|||
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
|
|
@ -6,7 +6,7 @@
|
|||
'description' => 'Visão geral de todas as suas contas sociais conectadas',
|
||||
'add_social' => 'Adicionar Rede Social',
|
||||
'add_social_title' => 'Conectar uma Conta Social',
|
||||
'add_social_description' => 'Escolha uma plataforma para conectar',
|
||||
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
|
||||
'no_accounts' => 'Nenhuma conta conectada ainda',
|
||||
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
|
||||
'added' => 'Adicionada :date',
|
||||
|
|
@ -20,10 +20,19 @@
|
|||
'view_profile' => 'Ver perfil',
|
||||
'disconnect' => 'Desconectar',
|
||||
|
||||
'tooltips' => [
|
||||
'instagram_facebook' => 'Conecta via sua Página do Facebook. Recomendado para contas business vinculadas a uma Página do Facebook.',
|
||||
'instagram_direct' => 'Conecta direto pelo Instagram. Para contas profissionais/criadores sem Página do Facebook.',
|
||||
'bluesky' => 'Não suportamos autenticação de dois fatores. Se estiver ativada no Bluesky, será necessário desativá-la.',
|
||||
'descriptions' => [
|
||||
'linkedin' => 'Conecte seu perfil pessoal do LinkedIn',
|
||||
'linkedin-page' => 'Conecte uma página de empresa do LinkedIn',
|
||||
'x' => 'Conecte sua conta do X (Twitter)',
|
||||
'tiktok' => 'Conecte sua conta do TikTok',
|
||||
'youtube' => 'Conecte um canal do YouTube',
|
||||
'facebook' => 'Conecte uma página do Facebook',
|
||||
'instagram' => 'Conecte uma conta profissional do Instagram',
|
||||
'instagram-facebook' => 'Conecte Instagram via página do Facebook',
|
||||
'threads' => 'Conecte sua conta do Threads',
|
||||
'pinterest' => 'Conecte sua conta do Pinterest',
|
||||
'bluesky' => 'Conecte sua conta do Bluesky',
|
||||
'mastodon' => 'Conecte sua conta do Mastodon',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
IconClock,
|
||||
IconFileCheck,
|
||||
IconFileText,
|
||||
IconBuildingStore,
|
||||
IconHash,
|
||||
IconLifebuoy,
|
||||
IconMessageCircle,
|
||||
|
|
@ -22,6 +21,7 @@ import { computed } from 'vue';
|
|||
|
||||
import { store as storePost } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
|
|
@ -45,7 +45,6 @@ import {
|
|||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { accounts, analytics, calendar } from '@/routes/app';
|
||||
import { index as brands } from '@/routes/app/brands';
|
||||
import { index as hashtags } from '@/routes/app/hashtags';
|
||||
import { index as labels } from '@/routes/app/labels';
|
||||
import { edit as editProfile } from '@/routes/app/profile';
|
||||
|
|
@ -102,7 +101,10 @@ const postsNavItems = computed<NavItem[]>(() => [
|
|||
},
|
||||
]);
|
||||
|
||||
const canManageWorkspace = computed(() => auth.value.currentWorkspace?.role !== 'member');
|
||||
const canManageWorkspace = computed(() => {
|
||||
const role = auth.value.currentWorkspace?.role;
|
||||
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
|
||||
});
|
||||
|
||||
const configNavItems = computed(() => {
|
||||
const items: NavItem[] = [
|
||||
|
|
@ -111,11 +113,6 @@ const configNavItems = computed(() => {
|
|||
href: accounts.url(),
|
||||
icon: IconAffiliate,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.config.brands'),
|
||||
href: brands.url(),
|
||||
icon: IconBuildingStore,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.config.hashtags'),
|
||||
href: hashtags.url(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconInfoCircle } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
|
|
@ -12,7 +11,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export interface AvailablePlatform {
|
||||
value: string;
|
||||
|
|
@ -44,13 +42,8 @@ const getPlatformLogo = (platform: string): string => {
|
|||
return logos[platform] || '/images/accounts/linkedin.png';
|
||||
};
|
||||
|
||||
const getPlatformTooltip = (platform: string): string | null => {
|
||||
const tooltips: Record<string, string> = {
|
||||
'instagram-facebook': trans('accounts.tooltips.instagram_facebook'),
|
||||
'instagram': trans('accounts.tooltips.instagram_direct'),
|
||||
'bluesky': trans('accounts.tooltips.bluesky'),
|
||||
};
|
||||
return tooltips[platform] || null;
|
||||
const getPlatformDescription = (platform: string): string => {
|
||||
return trans(`accounts.descriptions.${platform}`);
|
||||
};
|
||||
|
||||
const openOAuthPopup = (platformValue: string) => {
|
||||
|
|
@ -60,6 +53,8 @@ const openOAuthPopup = (platformValue: string) => {
|
|||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
|
||||
open.value = false;
|
||||
|
||||
window.open(
|
||||
url,
|
||||
'oauth-popup',
|
||||
|
|
@ -86,43 +81,31 @@ onUnmounted(() => {
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogContent class="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('accounts.add_social_title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('accounts.add_social_description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<button
|
||||
v-for="platform in platforms"
|
||||
:key="platform.value"
|
||||
variant="outline"
|
||||
class="h-auto flex-col gap-2 py-4"
|
||||
class="flex flex-col items-center gap-2 rounded-lg border border-border p-4 text-center transition-colors hover:bg-accent"
|
||||
@click="openOAuthPopup(platform.value)"
|
||||
>
|
||||
<div class="relative">
|
||||
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-8 w-8 rounded-full object-contain" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs">
|
||||
<template v-if="platform.label.includes('(')">
|
||||
{{ platform.label.split('(')[0].trim() }}
|
||||
</template>
|
||||
<template v-else>{{ platform.label }}</template>
|
||||
</span>
|
||||
<TooltipProvider v-if="getPlatformTooltip(platform.value)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<IconInfoCircle class="h-3 w-3 shrink-0 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-[250px]">
|
||||
<p>{{ getPlatformTooltip(platform.value) }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</Button>
|
||||
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-10 w-10 rounded-full object-contain" />
|
||||
<span class="text-sm font-medium">
|
||||
<template v-if="platform.label.includes('(')">
|
||||
{{ platform.label.split('(')[0].trim() }}
|
||||
</template>
|
||||
<template v-else>{{ platform.label }}</template>
|
||||
</span>
|
||||
<p class="line-clamp-2 text-xs leading-tight text-muted-foreground">
|
||||
{{ getPlatformDescription(platform.value) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
29
resources/js/components/auth/GoogleAuthButton.vue
Normal file
29
resources/js/components/auth/GoogleAuthButton.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
}>();
|
||||
|
||||
const page = usePage();
|
||||
const isEnabled = computed(() => page.props.googleAuthEnabled);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="isEnabled">
|
||||
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ label }}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { store as brandsStore } from '@/routes/app/brands';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(brandsStore.url(), {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
open.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('brands.create.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('brands.create.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="create-brand-name">{{ $t('brands.create.name') }}</Label>
|
||||
<Input
|
||||
id="create-brand-name"
|
||||
v-model="form.name"
|
||||
:placeholder="trans('brands.create.name_placeholder')"
|
||||
:class="{ 'border-destructive': form.errors.name }"
|
||||
/>
|
||||
<p v-if="form.errors.name" class="text-sm text-destructive">
|
||||
{{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('brands.create.submitting') : $t('brands.create.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { update as brandsUpdate } from '@/routes/app/brands';
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
brand: Brand | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
});
|
||||
|
||||
watch(() => props.brand, (brand) => {
|
||||
if (brand) {
|
||||
form.name = brand.name;
|
||||
form.clearErrors();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const submit = () => {
|
||||
if (!props.brand) return;
|
||||
form.put(brandsUpdate.url(props.brand.id), {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('brands.edit.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('brands.edit.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-brand-name">{{ $t('brands.edit.name') }}</Label>
|
||||
<Input
|
||||
id="edit-brand-name"
|
||||
v-model="form.name"
|
||||
:placeholder="trans('brands.edit.name_placeholder')"
|
||||
:class="{ 'border-destructive': form.errors.name }"
|
||||
/>
|
||||
<p v-if="form.errors.name" class="text-sm text-destructive">
|
||||
{{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('brands.edit.submitting') : $t('brands.edit.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -3,6 +3,7 @@ import { Form } from '@inertiajs/vue3';
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -26,10 +27,10 @@ import { store as storeInvite } from '@/routes/app/invites';
|
|||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const inviteRole = ref('member');
|
||||
const inviteRole = ref(WorkspaceRole.Member);
|
||||
|
||||
const onSuccess = () => {
|
||||
inviteRole.value = 'member';
|
||||
inviteRole.value = WorkspaceRole.Member;
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
|
@ -67,8 +68,9 @@ const onSuccess = () => {
|
|||
<SelectValue :placeholder="trans('settings.members.invite.role_placeholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">{{ $t('settings.members.roles.member') }}</SelectItem>
|
||||
<SelectItem value="admin">{{ $t('settings.members.roles.admin') }}</SelectItem>
|
||||
<SelectItem :value="WorkspaceRole.Member">{{ $t('settings.members.roles.member') }}</SelectItem>
|
||||
<SelectItem :value="WorkspaceRole.Admin">{{ $t('settings.members.roles.admin') }}</SelectItem>
|
||||
<SelectItem :value="WorkspaceRole.Viewer">{{ $t('settings.members.roles.viewer') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="role" :value="inviteRole" />
|
||||
|
|
|
|||
8
resources/js/enums/workspace-role.ts
Normal file
8
resources/js/enums/workspace-role.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const WorkspaceRole = {
|
||||
Owner: 'owner',
|
||||
Admin: 'admin',
|
||||
Member: 'member',
|
||||
Viewer: 'viewer',
|
||||
} as const;
|
||||
|
||||
export type WorkspaceRoleValue = (typeof WorkspaceRole)[keyof typeof WorkspaceRole];
|
||||
|
|
@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import { computed } from 'vue';
|
||||
|
||||
import { useActiveUrl } from '@/composables/useActiveUrl';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { index as apiKeys } from '@/routes/app/api-keys';
|
||||
import { index as billing } from '@/routes/app/billing';
|
||||
|
|
@ -15,7 +16,10 @@ import { type NavItem, type SharedData } from '@/types';
|
|||
|
||||
const page = usePage<SharedData>();
|
||||
const auth = computed(() => page.props.auth);
|
||||
const canManageWorkspace = computed(() => auth.value.currentWorkspace?.role !== 'member');
|
||||
const canManageWorkspace = computed(() => {
|
||||
const role = auth.value.currentWorkspace?.role;
|
||||
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
|
||||
});
|
||||
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ interface SocialAccount {
|
|||
is_active: boolean;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
brand: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -180,11 +179,6 @@ const handleDisconnect = (accountId: string) => {
|
|||
@{{ account.username || account.display_name }}
|
||||
</p>
|
||||
|
||||
<!-- Brand badge -->
|
||||
<p v-if="account.brand" class="mt-1 text-xs text-muted-foreground">
|
||||
{{ account.brand.name }}
|
||||
</p>
|
||||
|
||||
<!-- Added date -->
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{ $t('accounts.added', { date: date.diffForHumans(account.created_at) }) }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
|
||||
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -10,7 +11,6 @@ import { Label } from '@/components/ui/label';
|
|||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { register } from '@/routes';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
|
|
@ -31,18 +31,7 @@ defineProps<{
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<template v-if="$page.props.googleAuthEnabled">
|
||||
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ $t('auth.google_login') }}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<GoogleAuthButton :label="$t('auth.google_login')" />
|
||||
|
||||
<Form v-bind="store.form()" :reset-on-success="['password']" v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6">
|
||||
|
|
@ -87,4 +76,4 @@ defineProps<{
|
|||
</Form>
|
||||
</div>
|
||||
</AuthBase>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Form, Head } from '@inertiajs/vue3';
|
|||
import { IconEye, IconEyeOff } from '@tabler/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -17,7 +18,6 @@ import {
|
|||
} from '@/components/ui/tooltip';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { login } from '@/routes';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
import { store } from '@/routes/register';
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -27,7 +27,6 @@ defineProps<{
|
|||
|
||||
const showPassword = ref(false);
|
||||
|
||||
// Get user's timezone from browser
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
</script>
|
||||
|
||||
|
|
@ -40,18 +39,7 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|||
<Head :title="$t('auth.register.page_title')" />
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<template v-if="$page.props.googleAuthEnabled">
|
||||
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ $t('auth.google_signup') }}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<GoogleAuthButton :label="$t('auth.google_signup')" />
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, InfiniteScroll } from '@inertiajs/vue3';
|
||||
import { IconBuildingStore, IconPencil, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import CreateDialog from '@/components/brands/CreateDialog.vue';
|
||||
import EditDialog from '@/components/brands/EditDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as brandsIndex, destroy as brandsDestroy } from '@/routes/app/brands';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
social_accounts_count: number;
|
||||
}
|
||||
|
||||
interface ScrollBrands {
|
||||
data: Brand[];
|
||||
meta: {
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
brands: ScrollBrands;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const isCreateDialogOpen = ref(false);
|
||||
const isEditDialogOpen = ref(false);
|
||||
const editingBrand = ref<Brand | null>(null);
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
|
||||
{ title: trans('sidebar.config.brands'), href: brandsIndex.url() },
|
||||
]);
|
||||
|
||||
const openEditDialog = (brand: Brand) => {
|
||||
editingBrand.value = brand;
|
||||
isEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = (brandId: string) => {
|
||||
deleteModal.value?.open({
|
||||
url: brandsDestroy.url(brandId),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('sidebar.config.brands')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<Button :disabled="!canCreate" @click="isCreateDialogOpen = true">
|
||||
{{ $t('brands.new_brand') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<EmptyState
|
||||
v-if="brands.data.length === 0"
|
||||
:icon="IconBuildingStore"
|
||||
:title="$t('brands.no_brands_yet')"
|
||||
:description="$t('brands.no_brands_description')"
|
||||
/>
|
||||
|
||||
<div v-else>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="brand in brands.data" :key="brand.id">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-lg">{{ brand.name }}</CardTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(brand)">
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
@click="handleDelete(brand.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('brands.accounts_count', { count: String(brand.social_accounts_count) }) }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<InfiniteScroll data="brands" #default="{ loading }">
|
||||
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
<Card v-for="i in 3" :key="i">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Skeleton class="h-6 w-32" />
|
||||
<div class="flex gap-1">
|
||||
<Skeleton class="h-8 w-8" />
|
||||
<Skeleton class="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton class="h-4 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<CreateDialog v-model:open="isCreateDialogOpen" />
|
||||
<EditDialog v-model:open="isEditDialogOpen" :brand="editingBrand" />
|
||||
|
||||
<ConfirmDeleteModal ref="deleteModal" :title="$t('brands.delete.title')"
|
||||
:description="$t('brands.delete.description')" :action="$t('brands.delete.confirm')"
|
||||
:cancel="$t('brands.delete.cancel')" />
|
||||
</template>
|
||||
|
|
@ -4,6 +4,7 @@ import { IconUserPlus, IconUsers, IconMail, IconTrash, IconCrown, IconUser, Icon
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -58,7 +59,7 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
|||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
role: 'member',
|
||||
role: WorkspaceRole.Member,
|
||||
});
|
||||
|
||||
const submitInvite = () => {
|
||||
|
|
@ -91,7 +92,7 @@ const getRoleLabel = (role: string): string => {
|
|||
};
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
if (role === 'admin') return IconShield;
|
||||
if (role === WorkspaceRole.Admin) return IconShield;
|
||||
return IconUser;
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
|
|
@ -50,7 +51,6 @@ interface Member {
|
|||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
|
|
@ -175,12 +175,12 @@ const changeRole = (member: Member, role: string) => {
|
|||
{{ member.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="member.is_owner ? 'default' : 'secondary'">
|
||||
<Badge :variant="member.role === WorkspaceRole.Admin ? 'default' : 'secondary'">
|
||||
{{ member.role }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu v-if="!member.is_owner">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
<IconDots class="size-3.5" />
|
||||
|
|
@ -188,15 +188,15 @@ const changeRole = (member: Member, role: string) => {
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-if="member.role === 'member'"
|
||||
@click="changeRole(member, 'admin')"
|
||||
v-if="member.role === WorkspaceRole.Member"
|
||||
@click="changeRole(member, WorkspaceRole.Admin)"
|
||||
>
|
||||
<IconShield class="size-3.5" />
|
||||
{{ $t('settings.members.make_admin') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="member.role === 'admin'"
|
||||
@click="changeRole(member, 'member')"
|
||||
v-if="member.role === WorkspaceRole.Admin"
|
||||
@click="changeRole(member, WorkspaceRole.Member)"
|
||||
>
|
||||
<IconUser class="size-3.5" />
|
||||
{{ $t('settings.members.make_member') }}
|
||||
|
|
|
|||
6
resources/js/types/index.d.ts
vendored
6
resources/js/types/index.d.ts
vendored
|
|
@ -1,18 +1,20 @@
|
|||
import { InertiaLinkProps } from '@inertiajs/vue3';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
logo_url: string | null;
|
||||
timezone: string;
|
||||
role?: 'owner' | 'admin' | 'member' | null;
|
||||
role?: WorkspaceRole | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
user: User;
|
||||
role: 'owner' | 'admin' | 'member' | null;
|
||||
role: WorkspaceRole | null;
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
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;
|
||||
|
|
@ -164,12 +163,6 @@
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -3,21 +3,30 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceInvite;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->owner = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
|
||||
$this->account = Account::factory()->create();
|
||||
$this->owner = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->account->update(['owner_id' => $this->owner->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->owner->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('show invite displays invite details for guest', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'newuser@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->get(route('app.invites.show', $invite));
|
||||
|
|
@ -28,8 +37,7 @@
|
|||
->has('invite')
|
||||
->where('invite.id', $invite->id)
|
||||
->where('invite.email', 'newuser@example.com')
|
||||
->where('invite.role.value', WorkspaceRole::Member->value)
|
||||
->where('invite.workspace.name', $this->workspace->name)
|
||||
->where('invite.account.name', $this->account->name)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -39,10 +47,11 @@
|
|||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('app.invites.show', $invite));
|
||||
|
|
@ -61,8 +70,9 @@
|
|||
});
|
||||
|
||||
test('accept invite requires authentication', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('app.invites.accept', $invite));
|
||||
|
|
@ -70,31 +80,36 @@
|
|||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('accept invite adds user to workspace', function () {
|
||||
test('accept invite adds user to account and workspaces', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Admin,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('app.invites.accept', $invite));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
|
||||
// User should be member of workspace
|
||||
expect($this->workspace->hasMember($user))->toBeTrue();
|
||||
// User should be added to the account
|
||||
$user->refresh();
|
||||
expect($user->account_id)->toBe($this->account->id);
|
||||
|
||||
// Invite should be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->toBeNull();
|
||||
// User should be member of workspace
|
||||
expect($this->workspace->members()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
|
||||
// User's current workspace should be updated
|
||||
$user->refresh();
|
||||
expect($user->current_workspace_id)->toBe($this->workspace->id);
|
||||
|
||||
// Invite should be marked as accepted
|
||||
$invite->refresh();
|
||||
expect($invite->accepted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('accept invite fails for wrong email', function () {
|
||||
|
|
@ -103,10 +118,11 @@
|
|||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('app.invites.accept', $invite));
|
||||
|
|
@ -114,22 +130,23 @@
|
|||
$response->assertRedirect(route('app.calendar'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'danger');
|
||||
|
||||
// Invite should NOT be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->not->toBeNull();
|
||||
// Invite should NOT be accepted
|
||||
$invite->refresh();
|
||||
expect($invite->accepted_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('accept invite handles already member', function () {
|
||||
test('accept invite handles already member of account', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
$this->workspace->members()->attach($user->id, ['role' => WorkspaceRole::Member->value]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Admin,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('app.invites.accept', $invite));
|
||||
|
|
@ -137,13 +154,15 @@
|
|||
$response->assertRedirect(route('app.calendar'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'info');
|
||||
|
||||
// Invite should be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->toBeNull();
|
||||
// Invite should be marked as accepted
|
||||
$invite->refresh();
|
||||
expect($invite->accepted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('decline invite requires authentication', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('app.invites.decline', $invite));
|
||||
|
|
@ -157,10 +176,11 @@
|
|||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('app.invites.decline', $invite));
|
||||
|
|
@ -169,10 +189,7 @@
|
|||
$response->assertSessionHas('flash.bannerStyle', 'info');
|
||||
|
||||
// Invite should be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->toBeNull();
|
||||
|
||||
// User should NOT be member of workspace
|
||||
expect($this->workspace->hasMember($user))->toBeFalse();
|
||||
expect(Invite::find($invite->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('decline invite fails for wrong email', function () {
|
||||
|
|
@ -181,10 +198,11 @@
|
|||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('app.invites.decline', $invite));
|
||||
|
|
@ -193,5 +211,5 @@
|
|||
$response->assertSessionHas('flash.bannerStyle', 'danger');
|
||||
|
||||
// Invite should NOT be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->not->toBeNull();
|
||||
expect(Invite::find($invite->id))->not->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ function createApiToken(array $overrides = []): array
|
|||
|
||||
$result = createApiToken();
|
||||
|
||||
$result['workspace']->subscriptions()->create([
|
||||
$result['workspace']->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test_123',
|
||||
'stripe_status' => 'active',
|
||||
|
|
@ -161,7 +161,7 @@ function createApiToken(array $overrides = []): array
|
|||
|
||||
$result = createApiToken();
|
||||
|
||||
$result['workspace']->subscriptions()->create([
|
||||
$result['workspace']->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_trial_123',
|
||||
'stripe_status' => 'trialing',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
|
||||
$plainToken = 'tp_'.Str::random(48);
|
||||
$this->plainToken = $plainToken;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
|
||||
$plainToken = 'tp_'.Str::random(48);
|
||||
$this->plainToken = $plainToken;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
beforeEach(function () {
|
||||
$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->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,14 +4,23 @@
|
|||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$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->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
@ -35,9 +44,9 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('subscribe redirects to billing index when workspace has active subscription', function () {
|
||||
$this->workspace->subscriptions()->create([
|
||||
'type' => Workspace::SUBSCRIPTION_NAME,
|
||||
test('subscribe redirects to billing index when account has active subscription', function () {
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
|
|
@ -56,8 +65,8 @@
|
|||
});
|
||||
|
||||
test('billing index shows billing dashboard', function () {
|
||||
$this->workspace->subscriptions()->create([
|
||||
'type' => Workspace::SUBSCRIPTION_NAME,
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
|
|
@ -87,7 +96,7 @@
|
|||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Processing', false)
|
||||
->has('workspaceId')
|
||||
->has('accountId')
|
||||
->has('status')
|
||||
);
|
||||
});
|
||||
|
|
@ -126,13 +135,16 @@
|
|||
});
|
||||
|
||||
// Authorization tests
|
||||
test('admin cannot access billing index', function () {
|
||||
$admin = User::factory()->create(['setup' => Setup::Completed]);
|
||||
test('non-owner admin cannot access billing index', function () {
|
||||
$admin = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
|
||||
$admin->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->workspace->subscriptions()->create([
|
||||
'type' => Workspace::SUBSCRIPTION_NAME,
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
|
|
@ -142,12 +154,15 @@
|
|||
});
|
||||
|
||||
test('member cannot access billing index', function () {
|
||||
$member = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$member = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->workspace->subscriptions()->create([
|
||||
'type' => Workspace::SUBSCRIPTION_NAME,
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
<?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();
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
'setup' => Setup::Completed,
|
||||
]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->subscriptions()->create([
|
||||
$this->user->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_123',
|
||||
'stripe_status' => 'active',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
]);
|
||||
|
||||
$pivot = $user->workspaces->first()->pivot;
|
||||
expect($pivot->role)->toBe(Role::Owner->value);
|
||||
expect($pivot->role)->toBe(Role::Member->value);
|
||||
});
|
||||
|
||||
test('sets current workspace on user', function () {
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@
|
|||
|
||||
$this->app->instance(LinkedInPublisher::class, $publisher);
|
||||
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
|
||||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
|
|
@ -255,7 +255,7 @@
|
|||
|
||||
$this->app->instance(LinkedInPublisher::class, $publisher);
|
||||
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
|
||||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
|
@ -14,37 +14,32 @@
|
|||
|
||||
$this->plan = Plan::first();
|
||||
$this->plan->update([
|
||||
'brand_limit' => 5,
|
||||
'workspace_limit' => 5,
|
||||
'member_limit' => 5,
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'plan_id' => $this->plan->id,
|
||||
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
|
||||
$this->user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('can create brand within limit', function () {
|
||||
Brand::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cannot create brand beyond limit', function () {
|
||||
Brand::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('can invite member within limit', function () {
|
||||
expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cannot invite member beyond limit', function () {
|
||||
$members = User::factory()->count(4)->create();
|
||||
$members = User::factory()->count(4)->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
foreach ($members as $member) {
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
|
|
@ -54,18 +49,23 @@
|
|||
expect($this->user->can('inviteMember', $this->workspace))->toBeFalse();
|
||||
});
|
||||
|
||||
test('self hosted mode bypasses brand limit', function () {
|
||||
test('self hosted mode bypasses workspace limit', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
Brand::factory()->count(10)->create(['workspace_id' => $this->workspace->id]);
|
||||
Workspace::factory()->count(10)->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeTrue();
|
||||
expect($this->user->can('create', Workspace::class))->toBeTrue();
|
||||
});
|
||||
|
||||
test('self hosted mode bypasses member limit', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$members = User::factory()->count(10)->create();
|
||||
$members = User::factory()->count(10)->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
foreach ($members as $member) {
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@
|
|||
use App\Enums\User\Setup;
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'stripe_id' => 'cus_test123',
|
||||
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
|
||||
$this->user = User::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
|
||||
$this->listener = new StripeEventListener;
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->workspace->id === $this->workspace->id);
|
||||
Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->account->id === $this->account->id);
|
||||
});
|
||||
|
||||
test('subscription created marks setup as completed when on subscription step', function () {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->socialAccount = SocialAccount::factory()->create([
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Http\Middleware\Mcp\AuthenticateMcpToken;
|
||||
use App\Models\Account;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
|
@ -23,7 +24,7 @@ function createMcpToken(array $overrides = []): array
|
|||
|
||||
$user = data_get($overrides, 'user') ?? User::factory()->create();
|
||||
$workspace = data_get($overrides, 'workspace') ?? Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->syncWithoutDetaching([$user->id => ['role' => Role::Owner->value]]);
|
||||
$workspace->members()->syncWithoutDetaching([$user->id => ['role' => Role::Member->value]]);
|
||||
|
||||
$apiToken = ApiToken::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
|
|
@ -139,8 +140,8 @@ function callMiddleware(string $bearerToken = ''): JsonResponse|Response
|
|||
config(['trypost.self_hosted' => false]);
|
||||
$result = createMcpToken();
|
||||
|
||||
$result['workspace']->subscriptions()->create([
|
||||
'type' => Workspace::SUBSCRIPTION_NAME,
|
||||
$result['workspace']->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
|
|
@ -155,8 +156,8 @@ function callMiddleware(string $bearerToken = ''): JsonResponse|Response
|
|||
config(['trypost.self_hosted' => false]);
|
||||
$result = createMcpToken();
|
||||
|
||||
$result['workspace']->subscriptions()->create([
|
||||
'type' => Workspace::SUBSCRIPTION_NAME,
|
||||
$result['workspace']->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_trial',
|
||||
'stripe_status' => 'trialing',
|
||||
'stripe_price' => 'price_123',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
|
@ -32,10 +33,10 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$workspace->subscriptions()->create([
|
||||
$user->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_123',
|
||||
'stripe_status' => 'active',
|
||||
|
|
@ -52,11 +53,10 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
// Create a subscription with trial
|
||||
$workspace->subscriptions()->create([
|
||||
$user->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_trial_123',
|
||||
'stripe_status' => 'trialing',
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
|
@ -87,11 +87,10 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
// Create an expired trial subscription
|
||||
$workspace->subscriptions()->create([
|
||||
$user->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_expired_trial',
|
||||
'stripe_status' => 'canceled',
|
||||
|
|
@ -110,10 +109,10 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$workspace->subscriptions()->create([
|
||||
$user->account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_123',
|
||||
'stripe_status' => 'canceled',
|
||||
|
|
@ -126,21 +125,32 @@
|
|||
->assertRedirect(route('app.subscribe'));
|
||||
});
|
||||
|
||||
test('invited member can access workspace when owner has active subscription', function () {
|
||||
test('member can access workspace when account has active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$owner = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
|
||||
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
|
||||
$account = Account::factory()->create();
|
||||
$owner = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
$account->update(['owner_id' => $owner->id]);
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $owner->id,
|
||||
]);
|
||||
$workspace->members()->attach($owner->id, ['role' => Role::Member->value]);
|
||||
|
||||
$workspace->subscriptions()->create([
|
||||
$account->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_owner_123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$member = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$member = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
|
|
@ -149,14 +159,24 @@
|
|||
->assertOk();
|
||||
});
|
||||
|
||||
test('invited member is redirected to subscribe when owner has no subscription', function () {
|
||||
test('member is redirected to subscribe when account has no subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$owner = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
|
||||
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
|
||||
$account = Account::factory()->create();
|
||||
$owner = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
$account->update(['owner_id' => $owner->id]);
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $owner->id,
|
||||
]);
|
||||
|
||||
$member = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$member = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
|
|
@ -164,29 +184,3 @@
|
|||
->get(route('app.calendar'))
|
||||
->assertRedirect(route('app.subscribe'));
|
||||
});
|
||||
|
||||
test('invited member on own workspace without subscription is redirected to subscribe', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$owner = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$ownerWorkspace = Workspace::factory()->create(['user_id' => $owner->id]);
|
||||
$ownerWorkspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
|
||||
|
||||
$ownerWorkspace->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_owner_123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$member = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$ownerWorkspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
|
||||
$memberWorkspace = Workspace::factory()->create(['user_id' => $member->id]);
|
||||
$memberWorkspace->members()->attach($member->id, ['role' => Role::Owner->value]);
|
||||
$member->update(['current_workspace_id' => $memberWorkspace->id]);
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(route('app.calendar'))
|
||||
->assertRedirect(route('app.subscribe'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Role]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Connections]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
$user = User::factory()->create(['setup' => Setup::Subscription]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue