refactor: code review fixes — policies, enums, data_get, tests

- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
This commit is contained in:
Paulo Castellano 2026-03-31 00:40:18 -03:00
parent 7d95fd1efa
commit 74c6442728
97 changed files with 991 additions and 355 deletions

View file

@ -4,21 +4,23 @@
namespace App\Actions\Post;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Support\Arr;
class UpdatePost
{
/**
* @return array{post: Post, action: string|null}
* @return array{post: Post, action: PostAction|null}
*/
public static function execute(Workspace $workspace, Post $post, array $data): array
{
if ($post->status === PostStatus::Published) {
return ['post' => $post, 'action' => 'already_published'];
return ['post' => $post, 'action' => PostAction::AlreadyPublished];
}
$scheduledAt = $post->scheduled_at;
@ -29,12 +31,12 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
$status = data_get($data, 'status', $post->status);
$post->update([
'status' => $status === 'publishing' ? PostStatus::Publishing : $status,
'status' => $status === PostStatus::Publishing->value ? PostStatus::Publishing : $status,
'synced' => data_get($data, 'synced', $post->synced),
'scheduled_at' => $scheduledAt,
]);
if (array_key_exists('label_ids', $data)) {
if (Arr::has($data, 'label_ids')) {
$post->labels()->sync(data_get($data, 'label_ids', []));
}
@ -52,7 +54,10 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
if (data_get($platformData, 'meta') !== null) {
$postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first();
$updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta'));
if ($postPlatform) {
$updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta'));
}
}
$post->postPlatforms()
@ -60,15 +65,15 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
->update($updateData);
}
if ($status === 'publishing') {
if ($status === PostStatus::Publishing->value) {
$post->update(['scheduled_at' => now()]);
PublishPost::dispatch($post);
return ['post' => $post, 'action' => 'publishing'];
return ['post' => $post, 'action' => PostAction::Publishing];
}
if ($status === 'scheduled') {
return ['post' => $post, 'action' => 'scheduled'];
if ($status === PostStatus::Scheduled->value) {
return ['post' => $post, 'action' => PostAction::Scheduled];
}
return ['post' => $post, 'action' => null];

View file

@ -5,6 +5,7 @@
namespace App\Actions\User;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
@ -33,7 +34,7 @@ public static function execute(array $data): User
'timezone' => data_get($data, 'timezone', 'UTC'),
]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);

View file

@ -4,6 +4,7 @@
namespace App\Actions\Workspace;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
@ -17,7 +18,7 @@ public static function execute(User $user, array $data): Workspace
'timezone' => config('app.timezone', 'UTC'),
]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->switchWorkspace($workspace);
if ($user->hasActiveSubscription()) {

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\ApiToken;
enum Status: string
{
case Active = 'active';
case Expired = 'expired';
}

12
app/Enums/Post/Action.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\Post;
enum Action: string
{
case AlreadyPublished = 'already_published';
case Publishing = 'publishing';
case Scheduled = 'scheduled';
}

View file

@ -7,6 +7,7 @@
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\UpdatePost;
use App\Enums\Post\Action as PostAction;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
@ -79,7 +80,7 @@ public function update(Request $request, Post $post): PostResource|JsonResponse
$result = UpdatePost::execute($request->workspace, $post, $validated);
if (data_get($result, 'action') === 'already_published') {
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
return response()->json(
['message' => 'Cannot edit a published post.'],
Response::HTTP_UNPROCESSABLE_ENTITY

View file

@ -22,7 +22,7 @@ public function index(Request $request): Response|RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('manageTeam', $workspace);
return Inertia::render('settings/ApiKeys', [
'workspace' => $workspace,
@ -38,7 +38,7 @@ public function store(Request $request): RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('manageTeam', $workspace);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
@ -62,7 +62,7 @@ public function destroy(Request $request, ApiToken $apiToken): RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('manageTeam', $workspace);
if ($apiToken->workspace_id !== $workspace->id) {
abort(404);

View file

@ -4,6 +4,7 @@
namespace App\Http\Controllers\App;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -16,7 +17,9 @@ public function subscribe(Request $request): Response|RedirectResponse
{
$user = $request->user();
if ($user->subscribed('default')) {
$this->authorizeBilling($request);
if ($user->subscribed(User::SUBSCRIPTION_NAME)) {
return redirect()->route('app.billing.index');
}
@ -25,13 +28,15 @@ public function subscribe(Request $request): Response|RedirectResponse
]);
}
public function index(Request $request): Response
public function index(Request $request): Response|RedirectResponse
{
$this->authorizeBilling($request);
$user = $request->user();
$subscription = $user->subscription('default');
$subscription = $user->subscription(User::SUBSCRIPTION_NAME);
return Inertia::render('billing/Index', [
'hasSubscription' => $user->subscribed('default'),
'hasSubscription' => $user->subscribed(User::SUBSCRIPTION_NAME),
'onTrial' => $subscription?->onTrial() ?? false,
'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(),
'subscription' => $subscription?->only([
@ -58,9 +63,11 @@ public function index(Request $request): Response
public function checkout(Request $request): SymfonyResponse
{
$this->authorizeBilling($request);
$user = $request->user();
$subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id'))
$subscription = $user->newSubscription(User::SUBSCRIPTION_NAME, config('cashier.plans.monthly.price_id'))
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'))
->quantity(1);
@ -78,7 +85,7 @@ public function processing(Request $request): Response|RedirectResponse
$user = $request->user();
$status = $request->query('status', 'processing');
if ($user->subscribed('default')) {
if ($user->subscribed(User::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
@ -94,8 +101,19 @@ public function processing(Request $request): Response|RedirectResponse
public function portal(Request $request): RedirectResponse
{
$this->authorizeBilling($request);
return $request->user()->redirectToBillingPortal(
route('app.billing.index')
);
}
private function authorizeBilling(Request $request): void
{
$workspace = $request->user()->currentWorkspace;
if ($workspace) {
$this->authorize('manageBilling', $workspace);
}
}
}

View file

@ -120,9 +120,9 @@ public function duplicate(Media $media, Request $request): JsonResponse
$duplicates = [];
foreach ($targets as $target) {
$model = $this->resolveModel($target['model'], $target['model_id']);
$model = $this->resolveModel(data_get($target, 'model'), data_get($target, 'model_id'));
$this->authorizeModelOwnership($model, $request);
$collection = $target['collection'] ?? $media->collection;
$collection = data_get($target, 'collection', $media->collection);
$duplicate = $model->media()->create([
'group_id' => $media->group_id,

View file

@ -15,6 +15,10 @@ public function index(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return response()->json(['notifications' => [], 'unread_count' => 0]);
}
$notifications = $request->user()
->notifications()
->where('workspace_id', $workspace->id)
@ -51,6 +55,10 @@ public function markAllAsRead(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return response()->json(['success' => true]);
}
$request->user()
->notifications()
->where('workspace_id', $workspace->id)
@ -64,6 +72,10 @@ public function archiveAll(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return response()->json(['success' => true]);
}
$request->user()
->notifications()
->where('workspace_id', $workspace->id)

View file

@ -36,7 +36,7 @@ public function storeRole(Request $request): RedirectResponse
]);
$request->user()->update([
'persona' => $validated['persona'],
'persona' => data_get($validated, 'persona'),
'setup' => Setup::Connections,
]);
@ -92,7 +92,7 @@ public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
'setup' => Setup::Subscription,
]);
$subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id'))
$subscription = $user->newSubscription(User::SUBSCRIPTION_NAME, config('cashier.plans.monthly.price_id'))
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'))
->quantity(1);

View file

@ -7,6 +7,8 @@
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\UpdatePost;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Http\Requests\App\Post\UpdatePostRequest;
use App\Models\Post;
@ -34,9 +36,9 @@ public function index(Request $request, ?string $status = null): Response|Redire
if ($status) {
$query = match ($status) {
'draft' => $query->draft(),
'scheduled' => $query->scheduled(),
'published' => $query->published(),
PostStatus::Draft->value => $query->draft(),
PostStatus::Scheduled->value => $query->scheduled(),
PostStatus::Published->value => $query->published(),
default => $query,
};
}
@ -204,21 +206,21 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
$action = data_get($result, 'action');
if ($action === 'already_published') {
if ($action === PostAction::AlreadyPublished) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_published'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
if ($action === 'publishing') {
if ($action === PostAction::Publishing) {
session()->flash('flash.banner', __('posts.flash.publishing'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.posts.edit', $post);
}
if ($action === 'scheduled') {
if ($action === PostAction::Scheduled) {
session()->flash('flash.banner', __('posts.flash.scheduled'));
session()->flash('flash.bannerStyle', 'success');

View file

@ -27,7 +27,7 @@ public function update(Request $request): RedirectResponse
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
'password' => Hash::make(data_get($validated, 'password')),
]);
session()->flash('flash.banner', __('settings.flash.password_updated'));

View file

@ -8,6 +8,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Settings\ProfileDeleteRequest;
use App\Http\Requests\App\Settings\ProfileUpdateRequest;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -90,8 +91,8 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user = $request->user();
DB::transaction(function () use ($user) {
if ($user->subscribed('default')) {
$user->subscription('default')->cancelNow();
if ($user->subscribed(User::SUBSCRIPTION_NAME)) {
$user->subscription(User::SUBSCRIPTION_NAME)->cancelNow();
}
$user->subscriptions()->delete();
$user->update(['current_workspace_id' => null]);

View file

@ -6,6 +6,7 @@
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;
@ -94,7 +95,7 @@ public function settings(Request $request): Response|RedirectResponse
'name' => $member->name,
'email' => $member->email,
'role' => $member->pivot->role,
'is_owner' => $member->id === $workspace->user_id,
'is_owner' => $member->pivot->role === Role::Owner->value,
]);
$invitations = $workspace->invites()

View file

@ -23,7 +23,7 @@ public function index(Request $request): Response|RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
$hashtags = $workspace->hashtags()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
@ -47,7 +47,7 @@ public function store(Request $request): RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
@ -70,7 +70,7 @@ public function update(Request $request, WorkspaceHashtag $hashtag): RedirectRes
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
if ($hashtag->workspace_id !== $workspace->id) {
abort(404);
@ -97,7 +97,7 @@ public function destroy(Request $request, WorkspaceHashtag $hashtag): RedirectRe
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
if ($hashtag->workspace_id !== $workspace->id) {
abort(404);

View file

@ -34,7 +34,7 @@ public function index(Request $request): Response|RedirectResponse
->latest()
->get(),
'members' => $workspace->members()
->where('user_id', '!=', $workspace->user_id)
->wherePivot('role', '!=', WorkspaceRole::Owner->value)
->get()
->map(fn ($member) => [
'id' => $member->id,
@ -123,7 +123,9 @@ public function removeMember(Request $request, string $userId): RedirectResponse
$this->authorize('manageTeam', $workspace);
if ($workspace->user_id === $userId) {
$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.']);
}
@ -145,7 +147,9 @@ public function updateRole(Request $request, string $userId): RedirectResponse
$this->authorize('manageTeam', $workspace);
if ($workspace->user_id === $userId) {
$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.']);
}

View file

@ -23,7 +23,7 @@ public function index(Request $request): Response|RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
$labels = $workspace->labels()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
@ -47,7 +47,7 @@ public function store(Request $request): RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
@ -70,7 +70,7 @@ public function update(Request $request, WorkspaceLabel $label): RedirectRespons
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
if ($label->workspace_id !== $workspace->id) {
abort(404);
@ -97,7 +97,7 @@ public function destroy(Request $request, WorkspaceLabel $label): RedirectRespon
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
if ($label->workspace_id !== $workspace->id) {
abort(404);

View file

@ -79,9 +79,9 @@ public function store(Request $request): View|RedirectResponse
$data = $response->json();
// Get profile
$profileResponse = Http::withToken($data['accessJwt'])
$profileResponse = Http::withToken(data_get($data, 'accessJwt'))
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
'actor' => $data['did'],
'actor' => data_get($data, 'did'),
]);
$profile = $profileResponse->successful() ? $profileResponse->json() : [];
@ -95,16 +95,16 @@ public function store(Request $request): View|RedirectResponse
return back()->withErrors(['identifier' => 'Bluesky is already connected.']);
}
$avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null;
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
$accountData = [
'platform' => $this->platform->value,
'platform_user_id' => $data['did'],
'username' => $data['handle'],
'display_name' => $profile['displayName'] ?? $data['handle'],
'platform_user_id' => data_get($data, 'did'),
'username' => data_get($data, 'handle'),
'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')),
'avatar_url' => $avatarPath,
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
'meta' => [
'service' => $service,

View file

@ -101,21 +101,21 @@ public function callback(Request $request): View|RedirectResponse
// If only one page, connect directly
if (count($pages) === 1) {
$page = $pages[0];
$avatarPath = uploadFromUrl($page['picture']);
$avatarPath = uploadFromUrl(data_get($page, 'picture'));
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $page['id'],
'username' => $page['username'] ?? null,
'display_name' => $page['name'],
'platform_user_id' => data_get($page, 'id'),
'username' => data_get($page, 'username', null),
'display_name' => data_get($page, 'name'),
'avatar_url' => $avatarPath,
'access_token' => $page['access_token'],
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'meta' => [
'page_id' => $page['id'],
'page_id' => data_get($page, 'id'),
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
],
@ -130,17 +130,17 @@ public function callback(Request $request): View|RedirectResponse
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $page['id'],
'username' => $page['username'] ?? null,
'display_name' => $page['name'],
'platform_user_id' => data_get($page, 'id'),
'username' => data_get($page, 'username', null),
'display_name' => data_get($page, 'name'),
'avatar_url' => $avatarPath,
'access_token' => $page['access_token'],
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null, // Page tokens don't expire if user token is long-lived
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'page_id' => $page['id'],
'page_id' => data_get($page, 'id'),
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
],
@ -195,7 +195,7 @@ public function selectPage(Request $request)
return Inertia::render('accounts/FacebookPageSelect', [
'workspace' => $workspace,
'pages' => $oauthData['pages'],
'pages' => data_get($oauthData, 'pages'),
]);
}
@ -219,14 +219,14 @@ public function select(Request $request): View
}
try {
$selectedPage = collect($oauthData['pages'])->firstWhere('id', $request->page_id);
$selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('id', $request->page_id);
if (! $selectedPage) {
return $this->popupCallback(false, 'Page not found.', $this->platform->value);
}
$avatarPath = uploadFromUrl($selectedPage['picture']);
$reconnectId = $oauthData['reconnect_id'] ?? null;
$avatarPath = uploadFromUrl(data_get($selectedPage, 'picture'));
$reconnectId = data_get($oauthData, 'reconnect_id');
if ($reconnectId) {
// Reconnect existing account
@ -234,18 +234,18 @@ public function select(Request $request): View
if ($existingAccount) {
$existingAccount->update([
'platform_user_id' => $selectedPage['id'],
'username' => $selectedPage['username'] ?? null,
'display_name' => $selectedPage['name'],
'platform_user_id' => data_get($selectedPage, 'id'),
'username' => data_get($selectedPage, 'username') ?? null,
'display_name' => data_get($selectedPage, 'name'),
'avatar_url' => $avatarPath,
'access_token' => $selectedPage['access_token'],
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'meta' => [
'page_id' => $selectedPage['id'],
'user_id' => $oauthData['user_id'],
'user_token' => $oauthData['user_token'],
'page_id' => data_get($selectedPage, 'id'),
'user_id' => data_get($oauthData, 'user_id'),
'user_token' => data_get($oauthData, 'user_token'),
],
]);
$existingAccount->markAsConnected();
@ -259,19 +259,19 @@ public function select(Request $request): View
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $selectedPage['id'],
'username' => $selectedPage['username'] ?? null,
'display_name' => $selectedPage['name'],
'platform_user_id' => data_get($selectedPage, 'id'),
'username' => data_get($selectedPage, 'username') ?? null,
'display_name' => data_get($selectedPage, 'name'),
'avatar_url' => $avatarPath,
'access_token' => $selectedPage['access_token'],
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'page_id' => $selectedPage['id'],
'user_id' => $oauthData['user_id'],
'user_token' => $oauthData['user_token'],
'page_id' => data_get($selectedPage, 'id'),
'user_id' => data_get($oauthData, 'user_id'),
'user_token' => data_get($oauthData, 'user_token'),
],
]);
@ -306,12 +306,12 @@ private function fetchPages(string $userToken): array
$data = $response->json();
return collect($data['data'] ?? [])->map(fn ($page) => [
'id' => $page['id'],
'name' => $page['name'],
'username' => $page['username'] ?? null,
'picture' => $page['picture']['data']['url'] ?? null,
'access_token' => $page['access_token'],
return collect(data_get($data, 'data', []))->map(fn ($page) => [
'id' => data_get($page, 'id'),
'name' => data_get($page, 'name'),
'username' => data_get($page, 'username', null),
'picture' => data_get($page, 'picture.data.url'),
'access_token' => data_get($page, 'access_token'),
])->toArray();
} catch (\Exception $e) {
Log::error('Facebook pages fetch error', [

View file

@ -262,18 +262,16 @@ private function fetchOrganizations(string $accessToken): array
$data = $response->json();
$organizations = [];
foreach ($data['elements'] ?? [] as $element) {
$org = $element['organization~'] ?? null;
foreach (data_get($data, 'elements', []) as $element) {
$org = data_get($element, 'organization~', null);
if ($org) {
$logoUrl = null;
if (isset($org['logoV2']['original~']['elements'][0]['identifiers'][0]['identifier'])) {
$logoUrl = $org['logoV2']['original~']['elements'][0]['identifiers'][0]['identifier'];
}
$logoUrl = data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier');
$organizations[] = [
'id' => $org['id'],
'name' => $org['localizedName'] ?? 'Unknown',
'vanity_name' => $org['vanityName'] ?? null,
'id' => data_get($org, 'id'),
'name' => data_get($org, 'localizedName', 'Unknown'),
'vanity_name' => data_get($org, 'vanityName', null),
'logo' => $logoUrl,
];
}

View file

@ -85,6 +85,10 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($account->workspace_id !== $workspace->id) {

View file

@ -21,7 +21,11 @@ public function redirect(): RedirectResponse
public function callback(): RedirectResponse
{
$googleUser = Socialite::driver('google-auth')->user();
try {
$googleUser = Socialite::driver('google-auth')->user();
} catch (\Exception) {
return redirect()->route('login');
}
$user = User::where('email', $googleUser->getEmail())->first();

View file

@ -95,21 +95,21 @@ public function callback(Request $request): View|RedirectResponse
// If only one channel, connect directly (most common case)
if (count($channels) === 1) {
$channel = $channels[0];
$avatarPath = uploadFromUrl($channel['thumbnail']);
$avatarPath = uploadFromUrl(data_get($channel, 'thumbnail'));
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $channel['id'],
'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'),
'display_name' => $channel['title'],
'platform_user_id' => data_get($channel, 'id'),
'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'),
'display_name' => data_get($channel, 'title'),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $this->scopes,
'meta' => [
'channel_id' => $channel['id'],
'channel_id' => data_get($channel, 'id'),
'google_user_id' => $socialUser->getId(),
],
]);
@ -123,9 +123,9 @@ public function callback(Request $request): View|RedirectResponse
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $channel['id'],
'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'),
'display_name' => $channel['title'],
'platform_user_id' => data_get($channel, 'id'),
'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'),
'display_name' => data_get($channel, 'title'),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
@ -133,7 +133,7 @@ public function callback(Request $request): View|RedirectResponse
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'channel_id' => $channel['id'],
'channel_id' => data_get($channel, 'id'),
'google_user_id' => $socialUser->getId(),
],
]);
@ -328,13 +328,13 @@ private function fetchChannels(string $accessToken): array
$data = $response->json();
return collect($data['items'] ?? [])->map(fn ($channel) => [
'id' => $channel['id'],
'title' => $channel['snippet']['title'],
'description' => $channel['snippet']['description'] ?? '',
'thumbnail' => $channel['snippet']['thumbnails']['default']['url'] ?? null,
'custom_url' => $channel['snippet']['customUrl'] ?? null,
'subscriber_count' => $channel['statistics']['subscriberCount'] ?? 0,
return collect(data_get($data, 'items', []))->map(fn ($channel) => [
'id' => data_get($channel, 'id'),
'title' => data_get($channel, 'snippet.title'),
'description' => data_get($channel, 'snippet.description', ''),
'thumbnail' => data_get($channel, 'snippet.thumbnails.default.url'),
'custom_url' => data_get($channel, 'snippet.customUrl'),
'subscriber_count' => data_get($channel, 'statistics.subscriberCount', 0),
])->toArray();
} catch (\Exception $e) {
Log::error('YouTube channels fetch error', [

View file

@ -5,6 +5,7 @@
namespace App\Http\Middleware\Api;
use App\Models\ApiToken;
use App\Models\User;
use App\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
@ -60,6 +61,6 @@ private function hasActiveSubscription(Workspace $workspace): bool
return false;
}
return $owner->subscribed('default') || $owner->onTrial('default');
return $owner->subscribed(User::SUBSCRIPTION_NAME) || $owner->onTrial(User::SUBSCRIPTION_NAME);
}
}

View file

@ -4,6 +4,7 @@
namespace App\Http\Middleware\App;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -29,7 +30,7 @@ public function handle(Request $request, Closure $next): Response
}
// Allow access if user has active subscription or is on trial
if ($user->subscribed('default') || $user->onTrial('default')) {
if ($user->subscribed(User::SUBSCRIPTION_NAME) || $user->onTrial(User::SUBSCRIPTION_NAME)) {
return $next($request);
}
@ -39,7 +40,7 @@ public function handle(Request $request, Closure $next): Response
if ($currentWorkspace && $currentWorkspace->owner && $currentWorkspace->owner->id !== $user->id) {
$owner = $currentWorkspace->owner;
if ($owner->subscribed('default') || $owner->onTrial('default')) {
if ($owner->subscribed(User::SUBSCRIPTION_NAME) || $owner->onTrial(User::SUBSCRIPTION_NAME)) {
return $next($request);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Http\Middleware\Mcp;
use App\Models\ApiToken;
use App\Models\User;
use App\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
@ -64,6 +65,6 @@ private function hasActiveSubscription(Workspace $workspace): bool
return false;
}
return $owner->subscribed('default') || $owner->onTrial('default');
return $owner->subscribed(User::SUBSCRIPTION_NAME) || $owner->onTrial(User::SUBSCRIPTION_NAME);
}
}

View file

@ -64,7 +64,7 @@ public function handle(): void
$publisher = $this->getPublisher();
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished($result['id'], $result['url'] ?? null);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
} catch (TokenExpiredException $e) {
Log::error('Token expired while publishing to social platform', [
'post_platform_id' => $this->postPlatform->id,

View file

@ -52,7 +52,7 @@ public function handle(): void
}
// Send email (respects user preferences)
if ($this->mailable && $this->channel !== Channel::InApp && $this->user->wantsEmailFor($this->type->value)) {
if ($this->mailable && $this->channel !== Channel::InApp && $this->user->wantsEmailFor($this->type)) {
Mail::to($this->user)->send($this->mailable);
}
}

View file

@ -36,11 +36,11 @@ public function handle(): void
}
foreach ($this->calls as $call) {
match ($call['method']) {
'capture' => PostHog::capture($call['payload']),
'identify' => PostHog::identify($call['payload']),
'groupIdentify' => PostHog::groupIdentify($call['payload']),
default => Log::warning('SendPostHogEvent: unknown method', ['method' => $call['method']]),
match (data_get($call, 'method')) {
'capture' => PostHog::capture(data_get($call, 'payload')),
'identify' => PostHog::identify(data_get($call, 'payload')),
'groupIdentify' => PostHog::groupIdentify(data_get($call, 'payload')),
default => Log::warning('SendPostHogEvent: unknown method', ['method' => data_get($call, 'method')]),
};
}

View file

@ -25,8 +25,8 @@ public function handle(Request $request): ResponseFactory
$result = CreateApiKey::execute($request->user()->currentWorkspace, $validated);
return Response::structured([
...$result['token']->toArray(),
'token' => $result['plain_token'],
...data_get($result, 'token')->toArray(),
'token' => data_get($result, 'plain_token'),
]);
}

View file

@ -4,6 +4,7 @@
namespace App\Models;
use App\Enums\ApiToken\Status;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasVersion4Uuids as HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -60,10 +61,10 @@ protected function status(): Attribute
return Attribute::make(
get: function () {
if ($this->expires_at === null) {
return 'active';
return Status::Active->value;
}
return now()->greaterThan($this->expires_at) ? 'expired' : 'active';
return now()->greaterThan($this->expires_at) ? Status::Expired->value : Status::Active->value;
}
);
}

View file

@ -4,6 +4,8 @@
namespace App\Models\Traits;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -49,7 +51,7 @@ public function belongsToWorkspace(Workspace $workspace): bool
*/
public function ownedWorkspacesCount(): int
{
return $this->workspaces()->wherePivot('role', 'owner')->count();
return $this->workspaces()->wherePivot('role', Role::Owner->value)->count();
}
/**
@ -66,7 +68,7 @@ public function canCreateWorkspace(): bool
return $this->ownedWorkspacesCount() === 0;
}
$subscription = $this->subscription('default');
$subscription = $this->subscription(User::SUBSCRIPTION_NAME);
return $subscription && $this->ownedWorkspacesCount() < $subscription->quantity;
}
@ -82,7 +84,7 @@ public function incrementWorkspaceQuantity(): void
}
if ($this->hasActiveSubscription()) {
$this->subscription('default')->incrementQuantity();
$this->subscription(User::SUBSCRIPTION_NAME)->incrementQuantity();
}
}
@ -97,7 +99,7 @@ public function decrementWorkspaceQuantity(): void
}
if ($this->hasActiveSubscription()) {
$subscription = $this->subscription('default');
$subscription = $this->subscription(User::SUBSCRIPTION_NAME);
if ($subscription->quantity > 1) {
$subscription->decrementQuantity();
@ -119,7 +121,7 @@ public function syncWorkspaceQuantity(): void
$count = $this->ownedWorkspacesCount();
if ($count > 0) {
$this->subscription('default')->updateQuantity($count);
$this->subscription(User::SUBSCRIPTION_NAME)->updateQuantity($count);
}
}
}

View file

@ -4,6 +4,7 @@
namespace App\Models;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\Traits\HasMedia;
@ -23,6 +24,8 @@ class User extends Authenticatable implements MustVerifyEmail
/** @use HasFactory<UserFactory> */
use Billable, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
public const SUBSCRIPTION_NAME = 'default';
/**
* The attributes that are mass assignable.
*
@ -94,7 +97,7 @@ public function notificationPreference(): HasOne
return $this->hasOne(NotificationPreference::class);
}
public function wantsEmailFor(string $type): bool
public function wantsEmailFor(NotificationType $type): bool
{
$preference = $this->notificationPreference;
@ -103,9 +106,9 @@ public function wantsEmailFor(string $type): bool
}
return match ($type) {
'post_published' => $preference->post_published,
'post_failed', 'post_partially_published' => $preference->post_failed,
'account_disconnected' => $preference->account_disconnected,
NotificationType::PostPublished => $preference->post_published,
NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed,
NotificationType::AccountDisconnected => $preference->account_disconnected,
default => true,
};
}
@ -120,7 +123,7 @@ public function hasActiveSubscription(): bool
return true;
}
return $this->subscribed('default');
return $this->subscribed(self::SUBSCRIPTION_NAME);
}
/**

View file

@ -4,7 +4,7 @@
namespace App\Policies;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
@ -17,7 +17,7 @@ public function viewAny(User $user): bool
public function view(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace) || $workspace->members->contains($user);
return $this->isMember($user, $workspace);
}
public function create(User $user): bool
@ -27,45 +27,48 @@ public function create(User $user): bool
public function update(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace);
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
}
public function delete(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace);
return $this->hasRole($user, $workspace, [Role::Owner]);
}
public function restore(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace);
return $this->hasRole($user, $workspace, [Role::Owner]);
}
public function forceDelete(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace);
return $this->hasRole($user, $workspace, [Role::Owner]);
}
public function manageTeam(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace);
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
}
public function manageAccounts(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace);
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
}
public function createPost(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace) || $workspace->members->contains($user);
return $this->isMember($user, $workspace);
}
private function isOwner(User $user, Workspace $workspace): bool
public function manageBilling(User $user, Workspace $workspace): bool
{
return $user->id === $workspace->user_id;
return $this->hasRole($user, $workspace, [Role::Owner]);
}
private function isAdmin(User $user, Workspace $workspace): bool
/**
* @param Role[] $roles
*/
private function hasRole(User $user, Workspace $workspace, array $roles): bool
{
$member = $workspace->members()->where('user_id', $user->id)->first();
@ -73,6 +76,11 @@ private function isAdmin(User $user, Workspace $workspace): bool
return false;
}
return $member->pivot->role === WorkspaceRole::Admin->value;
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();
}
}

View file

@ -14,10 +14,6 @@ class PostHogService
*/
public function capture(string $distinctId, string $event, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
return;
}
$this->dispatch('capture', [
'distinctId' => $distinctId,
'event' => $event,
@ -30,10 +26,6 @@ public function capture(string $distinctId, string $event, array $properties = [
*/
public function identify(string $distinctId, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
return;
}
$this->dispatch('identify', [
'distinctId' => $distinctId,
'properties' => $properties,
@ -45,10 +37,6 @@ public function identify(string $distinctId, array $properties = []): void
*/
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
return;
}
$this->dispatch('groupIdentify', [
'groupType' => $groupType,
'groupKey' => $groupKey,
@ -61,6 +49,10 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper
*/
private function dispatch(string $method, array $payload): void
{
if (! config('services.posthog.api_key')) {
return;
}
try {
SendPostHogEvent::dispatch([
['method' => $method, 'payload' => $payload],

View file

@ -94,7 +94,7 @@ public function publish(PostPlatform $postPlatform): array
$data = $response->json();
// Extract post ID from URI (at://did/app.bsky.feed.post/xxx)
$uri = $data['uri'];
$uri = data_get($data, 'uri');
$postId = basename($uri);
Log::info('Bluesky post created successfully', [
@ -266,8 +266,8 @@ public function refreshToken(SocialAccount $account): void
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
@ -295,8 +295,8 @@ public function refreshToken(SocialAccount $account): void
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);

View file

@ -81,9 +81,9 @@ private function refreshLinkedInToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
$account->refresh();
@ -113,9 +113,9 @@ private function refreshXToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200),
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
]);
$account->refresh();
@ -132,8 +132,8 @@ private function refreshBlueskyToken(SocialAccount $account): void
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
@ -156,8 +156,8 @@ private function refreshBlueskyToken(SocialAccount $account): void
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
@ -196,8 +196,8 @@ private function refreshYouTubeToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
$account->refresh();
@ -224,9 +224,9 @@ private function refreshTikTokToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
$account->refresh();
@ -256,9 +256,9 @@ private function refreshPinterestToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
$account->refresh();
@ -280,8 +280,8 @@ private function refreshThreadsToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
$account->refresh();

View file

@ -99,7 +99,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
}
$data = $response->json();
$postId = $data['id'];
$postId = data_get($data, 'id');
return [
'id' => $postId,
@ -126,7 +126,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
}
$data = $response->json();
$postId = $data['post_id'] ?? $data['id'];
$postId = data_get($data, 'post_id', data_get($data, 'id'));
return [
'id' => $postId,
@ -192,7 +192,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
}
$data = $response->json();
$postId = $data['id'];
$postId = data_get($data, 'id');
return [
'id' => $postId,
@ -220,7 +220,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $
}
$data = $response->json();
$videoId = $data['id'];
$videoId = data_get($data, 'id');
return [
'id' => $videoId,
@ -247,7 +247,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
}
$data = $response->json();
$videoId = $data['video_id'];
$videoId = data_get($data, 'video_id');
// Upload the video file
$uploadResponse = Http::post("{$this->baseUrl}/{$videoId}", [

View file

@ -427,7 +427,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
}
$data = $response->json();
$status = $data['status'] ?? 'UNKNOWN';
$status = data_get($data, 'status', 'UNKNOWN');
Log::info('LinkedIn Page video processing status', ['status' => $status, 'attempt' => $i]);
@ -465,9 +465,9 @@ private function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn personal if it exists

View file

@ -408,7 +408,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
}
$data = $response->json();
$status = $data['status'] ?? 'UNKNOWN';
$status = data_get($data, 'status', 'UNKNOWN');
Log::info('LinkedIn video processing status', ['status' => $status, 'attempt' => $i]);
@ -446,9 +446,9 @@ private function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn Page if it exists

View file

@ -59,13 +59,13 @@ public function publish(PostPlatform $postPlatform): array
$data = $response->json();
Log::info('Mastodon post created', [
'id' => $data['id'],
'url' => $data['url'],
'id' => data_get($data, 'id'),
'url' => data_get($data, 'url'),
]);
return [
'id' => $data['id'],
'url' => $data['url'],
'id' => data_get($data, 'id'),
'url' => data_get($data, 'url'),
];
}
@ -100,9 +100,9 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
$data = $response->json();
Log::info('Mastodon media uploaded', ['id' => $data['id']]);
Log::info('Mastodon media uploaded', ['id' => data_get($data, 'id')]);
return $data['id'];
return data_get($data, 'id');
} catch (\Exception $e) {
Log::error('Mastodon media upload error', [
'error' => $e->getMessage(),

View file

@ -99,11 +99,11 @@ private function publishImagePin(PostPlatform $postPlatform): array
$data = $response->json();
Log::info('Pinterest pin created successfully', ['pin_id' => $data['id']]);
Log::info('Pinterest pin created successfully', ['pin_id' => data_get($data, 'id')]);
return [
'id' => $data['id'],
'url' => "https://pinterest.com/pin/{$data['id']}",
'id' => data_get($data, 'id'),
'url' => 'https://pinterest.com/pin/'.data_get($data, 'id'),
];
}
@ -226,11 +226,11 @@ private function publishVideoPin(PostPlatform $postPlatform): array
$data = $response->json();
Log::info('Pinterest video pin created successfully', ['pin_id' => $data['id']]);
Log::info('Pinterest video pin created successfully', ['pin_id' => data_get($data, 'id')]);
return [
'id' => $data['id'],
'url' => "https://pinterest.com/pin/{$data['id']}",
'id' => data_get($data, 'id'),
'url' => 'https://pinterest.com/pin/'.data_get($data, 'id'),
];
}
@ -292,11 +292,11 @@ private function publishCarousel(PostPlatform $postPlatform): array
$data = $response->json();
Log::info('Pinterest carousel created successfully', ['pin_id' => $data['id']]);
Log::info('Pinterest carousel created successfully', ['pin_id' => data_get($data, 'id')]);
return [
'id' => $data['id'],
'url' => "https://pinterest.com/pin/{$data['id']}",
'id' => data_get($data, 'id'),
'url' => 'https://pinterest.com/pin/'.data_get($data, 'id'),
];
}
@ -318,7 +318,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
}
$data = $response->json();
$status = $data['status'] ?? 'unknown';
$status = data_get($data, 'status', 'unknown');
Log::info('Pinterest media processing status', [
'media_id' => $mediaId,
@ -331,7 +331,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
}
if ($status === 'failed') {
$failureCode = $data['failure_code'] ?? 'unknown';
$failureCode = data_get($data, 'failure_code', 'unknown');
throw new \Exception("Pinterest media processing failed: {$failureCode}");
}
@ -363,9 +363,9 @@ public function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : now()->addDays(30),
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : now()->addDays(30),
]);
Log::info('Pinterest token refreshed successfully');

View file

@ -275,7 +275,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
}
$data = $statusResponse->json();
$status = $data['status'] ?? 'UNKNOWN';
$status = data_get($data, 'status', 'UNKNOWN');
Log::info('Threads media processing status', [
'container_id' => $containerId,
@ -289,7 +289,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
}
if ($status === 'ERROR') {
$errorMessage = $data['error_message'] ?? 'Unknown error';
$errorMessage = data_get($data, 'error_message', 'Unknown error');
throw new \Exception('Threads media processing failed: '.$errorMessage);
}
@ -316,8 +316,8 @@ private function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
Log::info('Threads token refreshed successfully');

View file

@ -111,7 +111,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
Log::info('TikTok video init response', ['data' => $data]);
$publishId = $data['data']['publish_id'] ?? null;
$publishId = data_get($data, 'data')['publish_id'] ?? null;
if (! $publishId) {
throw new \Exception('TikTok did not return a publish_id');
@ -172,7 +172,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
Log::info('TikTok photo init response', ['data' => $data]);
$publishId = $data['data']['publish_id'] ?? null;
$publishId = data_get($data, 'data')['publish_id'] ?? null;
if (! $publishId) {
throw new \Exception('TikTok did not return a publish_id');
@ -207,7 +207,7 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20):
}
$data = $response->json();
$status = $data['data']['status'] ?? 'UNKNOWN';
$status = data_get($data, 'data')['status'] ?? 'UNKNOWN';
Log::info('TikTok publish status', [
'status' => $status,
@ -216,11 +216,11 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20):
]);
if ($status === 'PUBLISH_COMPLETE') {
return $data['data'] ?? [];
return data_get($data, 'data', []);
}
if (in_array($status, ['FAILED', 'PUBLISH_FAILED'])) {
$errorCode = $data['data']['fail_reason'] ?? 'Unknown error';
$errorCode = data_get($data, 'data')['fail_reason'] ?? 'Unknown error';
throw new \Exception("TikTok publish failed: {$errorCode}");
}
@ -264,9 +264,9 @@ private function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
Log::info('TikTok token refreshed successfully');

View file

@ -60,7 +60,7 @@ public function publish(PostPlatform $postPlatform): array
Log::info('X media upload response', ['response' => $uploadedMedia]);
// v2 API returns data.id, v1 returns media_id
$mediaId = $uploadedMedia['data']['id'] ?? $uploadedMedia['media_id'] ?? null;
$mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id'));
if ($mediaId) {
$mediaIds[] = $mediaId;
}
@ -350,9 +350,9 @@ private function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200),
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
]);
}

View file

@ -145,7 +145,7 @@ private function publishShort(PostPlatform $postPlatform, $media): array
Log::info('YouTube upload response', ['data' => $data]);
$videoId = $data['id'] ?? null;
$videoId = data_get($data, 'id', null);
if (! $videoId) {
throw new \Exception('YouTube did not return a video ID');
@ -197,9 +197,9 @@ private function refreshToken(SocialAccount $account): void
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
Log::info('YouTube token refreshed successfully');

View file

@ -72,7 +72,7 @@ protected function exchangeForLongLivedToken(array $data): array
RequestOptions::QUERY => [
'grant_type' => 'ig_exchange_token',
'client_secret' => $this->clientSecret,
'access_token' => $data['access_token'],
'access_token' => data_get($data, 'access_token'),
],
]);

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_es.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_pt-BR.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -197,7 +197,7 @@ const switchWorkspace = (workspaceId: string) => {
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child tooltip="Feedback">
<SidebarMenuButton as-child :tooltip="trans('sidebar.support.share_feedback')">
<a href="https://github.com/trypost-it/trypost/discussions" target="_blank"
rel="noopener noreferrer">
<IconMessageCircle />
@ -206,7 +206,7 @@ const switchWorkspace = (workspaceId: string) => {
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton as-child tooltip="Docs">
<SidebarMenuButton as-child :tooltip="trans('sidebar.support.docs')">
<a href="https://trypost.it/docs" target="_blank" rel="noopener noreferrer">
<IconLifebuoy />
<span>{{ $t('sidebar.support.docs') }}</span>

View file

@ -50,7 +50,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
// Parse input value into date
function parseInput(value: string) {
const parseInput = (value: string) => {
if (!value) return undefined;
try {
@ -62,7 +62,7 @@ function parseInput(value: string) {
return undefined;
}
return undefined;
}
};
const internalDate = ref(parseInput(props.modelValue));
const popoverOpen = ref(false);
@ -94,7 +94,7 @@ const minutes = computed(() => {
});
// Build full datetime string
function buildDateTime(dateStr: string | null): string | null {
const buildDateTime = (dateStr: string | null): string | null => {
if (!dateStr) return null;
if (!props.showTime) {
@ -103,15 +103,14 @@ function buildDateTime(dateStr: string | null): string | null {
const timeStr = `${selectedHour.value}:${selectedMinute.value}:00`;
return `${dateStr}T${timeStr}`;
}
};
// Handle time change
function onTimeChange() {
const onTimeChange = () => {
if (internalDate.value) {
const dateStr = internalDate.value.toString();
emit('update:modelValue', buildDateTime(dateStr));
}
}
};
// Parse input value into date component
const isInternalUpdate = ref(false);

View file

@ -10,7 +10,9 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import dayjs from '@/dayjs';
import { accounts } from '@/routes/app';
import { index, read, readAll, archiveAll } from '@/routes/app/notifications';
import { edit as editPost } from '@/routes/app/posts';
interface Notification {
id: string;
@ -54,7 +56,7 @@ const handleMarkAsRead = async (notification: Notification) => {
credentials: 'same-origin',
});
notification.read_at = new Date().toISOString();
notification.read_at = dayjs().toISOString();
unreadCount.value = Math.max(0, unreadCount.value - 1);
};
@ -67,7 +69,7 @@ const handleMarkAllAsRead = async () => {
notifications.value = notifications.value.map((n) => ({
...n,
read_at: n.read_at ?? new Date().toISOString(),
read_at: n.read_at ?? dayjs().toISOString(),
}));
unreadCount.value = 0;
};
@ -91,9 +93,9 @@ const handleNotificationClick = (notification: Notification) => {
close();
if (notification.data?.post_id) {
router.visit(`/posts/${notification.data.post_id}/edit`);
router.visit(editPost.url(notification.data.post_id));
} else if (notification.data?.social_account_id || notification.data?.workspace_id) {
router.visit('/accounts');
router.visit(accounts.url());
}
};

View file

@ -23,15 +23,15 @@ const page = usePage();
const currentWorkspace = computed<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
function switchWorkspace(workspace: Workspace) {
const switchWorkspace = (workspace: Workspace) => {
router.post(switchMethod.url(workspace.id), {}, {
preserveScroll: true,
});
}
};
function createWorkspace() {
const createWorkspace = () => {
router.visit(createWorkspaceRoute.url());
}
};
</script>
<template>

View file

@ -1,7 +1,11 @@
<script setup lang="ts">
import dayjs from '@/dayjs';
defineProps<{
title?: string;
}>();
const currentYear = dayjs().year();
</script>
<template>
@ -26,7 +30,7 @@ defineProps<{
<footer class="border-t mt-auto">
<div class="mx-auto max-w-5xl px-6 py-8">
<p class="text-sm text-muted-foreground text-center">
&copy; {{ new Date().getFullYear() }} TryPost. All rights reserved.
&copy; {{ currentYear }} TryPost. All rights reserved.
</p>
</div>
</footer>

View file

@ -47,7 +47,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<Label for="identifier">{{ $t('accounts.bluesky.email') }}</Label>
<Input id="identifier" name="identifier" v-model="identifier" type="text"
:placeholder="trans('accounts.bluesky.email_placeholder')" :class="{ 'border-destructive': errors?.identifier }"
required />
/>
<p v-if="errors?.identifier" class="text-sm text-destructive">
{{ errors.identifier }}
</p>
@ -57,7 +57,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<Label for="password">{{ $t('accounts.bluesky.app_password') }}</Label>
<Input id="password" name="password" v-model="password" type="password"
:placeholder="trans('accounts.bluesky.app_password_placeholder')" :class="{ 'border-destructive': errors?.password }"
required />
/>
<p v-if="errors?.password" class="text-sm text-destructive">
{{ errors.password }}
</p>

View file

@ -57,7 +57,6 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
type="url"
:placeholder="trans('accounts.mastodon.instance_placeholder')"
:class="{ 'border-destructive': errors?.instance }"
required
/>
<p v-if="errors?.instance" class="text-sm text-destructive">
{{ errors.instance }}

View file

@ -36,7 +36,7 @@ defineProps<{
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">{{ $t('auth.login.email') }}</Label>
<Input id="email" type="email" name="email" required autofocus :tabindex="1" autocomplete="email"
<Input id="email" type="email" name="email" autofocus :tabindex="1" autocomplete="email"
placeholder="email@example.com" :default-value="email ?? ''" />
<InputError :message="errors.email" />
</div>
@ -48,7 +48,7 @@ defineProps<{
{{ $t('auth.login.forgot_password') }}
</TextLink>
</div>
<Input id="password" type="password" name="password" required :tabindex="2"
<Input id="password" type="password" name="password" :tabindex="2"
autocomplete="current-password" :placeholder="$t('auth.login.password')" />
<InputError :message="errors.password" />
</div>

View file

@ -53,7 +53,6 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
<Input
id="name"
type="text"
required
autofocus
:tabindex="1"
autocomplete="name"
@ -68,7 +67,6 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
<Input
id="email"
type="email"
required
:tabindex="2"
autocomplete="email"
name="email"

View file

@ -51,19 +51,19 @@ const breadcrumbs: BreadcrumbItemType[] = [
},
];
function openPortal() {
const openPortal = () => {
window.location.href = portal.url();
}
};
function getStatusLabel(status: string): string {
const getStatusLabel = (status: string): string => {
return trans(`billing.status.${status}`) || status;
}
};
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
if (status === 'active' || status === 'trialing') return 'default';
if (status === 'canceled' || status === 'past_due' || status === 'unpaid') return 'destructive';
return 'secondary';
}
};
</script>
<template>

View file

@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { storeConnect } from '@/actions/App/Http/Controllers/App/OnboardingController';
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { role } from '@/routes/app/onboarding';
export interface SocialAccount {
id: string;
@ -137,7 +138,7 @@ const submit = () => {
<p class="text-muted-foreground">
{{ $t('onboarding.connect.error') }}
</p>
<Button variant="outline" @click="router.visit('/onboarding/role')">
<Button variant="outline" @click="router.visit(role.url())">
{{ $t('onboarding.connect.go_back') }}
</Button>
</div>

View file

@ -332,7 +332,7 @@ const contentTypeKeys: Record<string, string[]> = {
'mastodon': ['mastodon_post'],
};
function getDefaultContentType(platform: string): string {
const getDefaultContentType = (platform: string): string => {
const defaults: Record<string, string> = {
'instagram': 'instagram_feed',
'linkedin': 'linkedin_post',
@ -346,23 +346,23 @@ function getDefaultContentType(platform: string): string {
'bluesky': 'bluesky_post',
};
return defaults[platform] || '';
}
};
function getContentTypeOptions(platform: string): ContentTypeOption[] {
const getContentTypeOptions = (platform: string): ContentTypeOption[] => {
const keys = contentTypeKeys[platform] || [];
return keys.map(key => ({
value: key,
label: trans(`posts.content_types.${key}.label`),
description: trans(`posts.content_types.${key}.description`),
}));
}
};
function getPlatformData(platform: string): Record<string, any> {
const getPlatformData = (platform: string): Record<string, any> => {
if (platform === 'pinterest') {
return { boards: props.pinterestBoards };
}
return {};
}
};
const getConfig = (postPlatform: PostPlatform): PlatformConfig => {
return props.platformConfigs[postPlatform.social_account_id] || {

View file

@ -61,39 +61,39 @@ const form = useForm({
role: 'member',
});
function submitInvite() {
const submitInvite = () => {
form.post(storeInvite.url(), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
}
};
function cancelInvite(inviteId: string) {
const cancelInvite = (inviteId: string) => {
if (confirm(trans('settings.members.invite.cancel_confirm'))) {
router.delete(destroyInvite.url(inviteId), {
preserveScroll: true,
});
}
}
};
function handleRemoveMember(memberId: string) {
const handleRemoveMember = (memberId: string) => {
if (confirm(trans('settings.members.list.remove_confirm'))) {
router.delete(removeMember.url(memberId), {
preserveScroll: true,
});
}
}
};
function getRoleLabel(role: string): string {
const getRoleLabel = (role: string): string => {
return trans(`settings.members.roles.${role}`);
}
};
function getRoleIcon(role: string) {
const getRoleIcon = (role: string) => {
if (role === 'admin') return IconShield;
return IconUser;
}
};
</script>
<template>

View file

@ -75,7 +75,6 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
id="name"
name="name"
:default-value="user.name"
required
autocomplete="name"
:placeholder="trans('settings.profile.name_placeholder')"
/>
@ -89,7 +88,6 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
type="email"
name="email"
:default-value="user.email"
required
autocomplete="username"
:placeholder="trans('settings.profile.email_placeholder')"
/>

View file

@ -4,6 +4,7 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Models\ApiToken;
use App\Models\Post;
use App\Models\SocialAccount;
@ -15,7 +16,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' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$plainToken = 'tp_'.Str::random(48);
$this->plainToken = $plainToken;

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\ApiToken;
use App\Models\User;
use App\Models\Workspace;
@ -10,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' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->user->refresh();
});
@ -77,3 +78,25 @@
->delete(route('app.api-keys.destroy', $token))
->assertNotFound();
});
it('member cannot create api key', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$this->actingAs($member)
->post(route('app.api-keys.store'), ['name' => 'Test Key'])
->assertForbidden();
});
it('member cannot delete api key', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$token = ApiToken::factory()->create(['workspace_id' => $this->workspace->id]);
$this->actingAs($member)
->delete(route('app.api-keys.destroy', $token))
->assertForbidden();
});

View file

@ -3,12 +3,14 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
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->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -96,3 +98,36 @@
$response->assertRedirect(route('login'));
});
// Authorization tests
test('admin cannot access subscribe page', function () {
$admin = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
$admin->update(['current_workspace_id' => $this->workspace->id]);
$this->actingAs($admin)->get(route('app.subscribe'))->assertForbidden();
});
test('member cannot access subscribe page', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$this->actingAs($member)->get(route('app.subscribe'))->assertForbidden();
});
test('admin cannot access billing index', function () {
$admin = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
$admin->update(['current_workspace_id' => $this->workspace->id]);
$this->actingAs($admin)->get(route('app.billing.index'))->assertForbidden();
});
test('member cannot access billing index', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$this->actingAs($member)->get(route('app.billing.index'))->assertForbidden();
});

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Actions\User\CreateUser;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
test('creates user with correct attributes', function () {
$user = CreateUser::execute([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);
expect($user->name)->toBe('John Doe');
expect($user->email)->toBe('john@example.com');
expect($user->setup)->toBe(Setup::Role);
expect($user->email_verified_at)->toBeNull();
});
test('creates default workspace for new user', function () {
$user = CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => 'password123',
]);
expect($user->workspaces)->toHaveCount(1);
expect($user->workspaces->first()->name)->toBe("Jane Doe's Workspace");
});
test('attaches user as workspace owner', function () {
$user = CreateUser::execute([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
]);
$pivot = $user->workspaces->first()->pivot;
expect($pivot->role)->toBe(Role::Owner->value);
});
test('sets current workspace on user', function () {
$user = CreateUser::execute([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
]);
expect($user->current_workspace_id)->toBe($user->workspaces->first()->id);
});
test('uses provided timezone for workspace', function () {
$user = CreateUser::execute([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
'timezone' => 'America/Sao_Paulo',
]);
expect($user->workspaces->first()->timezone)->toBe('America/Sao_Paulo');
});
test('defaults timezone to UTC', function () {
$user = CreateUser::execute([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
]);
expect($user->workspaces->first()->timezone)->toBe('UTC');
});
test('invite registration sets setup to completed and verifies email', function () {
$user = CreateUser::execute([
'name' => 'Invited User',
'email' => 'invited@example.com',
'password' => 'password123',
'is_invite' => true,
]);
expect($user->setup)->toBe(Setup::Completed);
expect($user->email_verified_at)->not->toBeNull();
});
test('creates user without password for social login', function () {
$user = CreateUser::execute([
'name' => 'Social User',
'email' => 'social@example.com',
'email_verified_at' => now(),
]);
expect($user->password)->toBeNull();
expect($user->email_verified_at)->not->toBeNull();
});

View file

@ -5,9 +5,11 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
use App\Jobs\PublishToSocialPlatform;
use App\Jobs\SendNotification;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -16,6 +18,7 @@
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Mail::fake();
@ -181,6 +184,23 @@
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});
test('publish to social platform skips publishing when account is inactive', function () {
Event::fake();
$this->socialAccount->update(['is_active' => false]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_inactive'));
});
test('publish to social platform skips publishing when account token is expired', function () {
Event::fake();
@ -199,3 +219,42 @@
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});
test('publish to social platform dispatches success notification when all platforms published', function () {
Event::fake();
Queue::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::Published);
Queue::assertPushed(SendNotification::class);
});
test('publish to social platform dispatches failure notification when platform fails', function () {
Event::fake();
Queue::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new Exception('API error'));
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::Failed);
Queue::assertPushed(SendNotification::class);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
@ -11,7 +12,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -31,7 +32,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$user->subscriptions()->create([
@ -51,7 +52,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
// Create a subscription with trial
@ -73,7 +74,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -86,7 +87,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
// Create an expired trial subscription
@ -109,7 +110,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$user->subscriptions()->create([
@ -130,7 +131,7 @@
$owner = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => 'owner']);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$owner->subscriptions()->create([
'type' => 'default',
@ -140,7 +141,7 @@
]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$workspace->members()->attach($member->id, ['role' => 'member']);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($member)
@ -153,10 +154,10 @@
$owner = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => 'owner']);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$workspace->members()->attach($member->id, ['role' => 'member']);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($member)
@ -169,7 +170,7 @@
$owner = User::factory()->create(['setup' => Setup::Completed]);
$ownerWorkspace = Workspace::factory()->create(['user_id' => $owner->id]);
$ownerWorkspace->members()->attach($owner->id, ['role' => 'owner']);
$ownerWorkspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$owner->subscriptions()->create([
'type' => 'default',
@ -179,10 +180,10 @@
]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$ownerWorkspace->members()->attach($member->id, ['role' => 'member']);
$ownerWorkspace->members()->attach($member->id, ['role' => Role::Member->value]);
$memberWorkspace = Workspace::factory()->create(['user_id' => $member->id]);
$memberWorkspace->members()->attach($member->id, ['role' => 'owner']);
$memberWorkspace->members()->attach($member->id, ['role' => Role::Owner->value]);
$member->update(['current_workspace_id' => $memberWorkspace->id]);
$this->actingAs($member)

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
@ -11,7 +12,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -24,7 +25,7 @@
$user = User::factory()->create(['setup' => Setup::Role]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -37,7 +38,7 @@
$user = User::factory()->create(['setup' => Setup::Connections]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -50,7 +51,7 @@
$user = User::factory()->create(['setup' => Setup::Subscription]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Notification;
use App\Models\User;
use App\Models\Workspace;
@ -10,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' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -6,6 +6,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -17,6 +18,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->user->update(['current_workspace_id' => $this->workspace->id]);
$this->socialAccount = SocialAccount::factory()->create([
@ -497,3 +499,24 @@
$response->assertSessionHasErrors('label_ids.0');
});
// Member authorization tests
test('member can view posts index', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($member)->get(route('app.posts.index'));
$response->assertOk();
});
test('member can create post', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($member)->post(route('app.posts.store'));
$response->assertRedirect();
});

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
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->user->workspaces()->attach($this->workspace->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('search returns matching posts by platform content', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$matchingPost = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
PostPlatform::factory()->create([
'post_id' => $matchingPost->id,
'social_account_id' => $account->id,
'content' => 'Hello marketing world',
'enabled' => true,
]);
$nonMatchingPost = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
PostPlatform::factory()->create([
'post_id' => $nonMatchingPost->id,
'social_account_id' => $account->id,
'content' => 'Something else entirely',
'enabled' => true,
]);
$response = $this->actingAs($this->user)->get(route('app.posts.index', ['search' => 'marketing']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 1)
->where('filters.search', 'marketing')
);
});
test('search with no matches returns empty', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'content' => 'Hello world',
'enabled' => true,
]);
$response = $this->actingAs($this->user)->get(route('app.posts.index', ['search' => 'nonexistent']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 0)
->where('filters.search', 'nonexistent')
);
});
test('empty search returns all posts', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
Post::factory()->count(3)->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id])->each(function ($post) use ($account) {
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'enabled' => true,
]);
});
$response = $this->actingAs($this->user)->get(route('app.posts.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 3)
->where('filters.search', '')
);
});
test('search is case insensitive', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'content' => 'MARKETING CAMPAIGN',
'enabled' => true,
]);
$response = $this->actingAs($this->user)->get(route('app.posts.index', ['search' => 'marketing']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 1)
);
});

View file

@ -5,6 +5,7 @@
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Jobs\SendNotification;
use App\Mail\PostPublished;
use App\Models\Notification;
@ -17,7 +18,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' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -82,16 +83,16 @@
'account_disconnected' => false,
]);
expect($this->user->wantsEmailFor('post_published'))->toBeFalse();
expect($this->user->wantsEmailFor('post_failed'))->toBeTrue();
expect($this->user->wantsEmailFor('post_partially_published'))->toBeTrue(); // maps to post_failed
expect($this->user->wantsEmailFor('account_disconnected'))->toBeFalse();
expect($this->user->wantsEmailFor(Type::PostPublished))->toBeFalse();
expect($this->user->wantsEmailFor(Type::PostFailed))->toBeTrue();
expect($this->user->wantsEmailFor(Type::PostPartiallyPublished))->toBeTrue(); // maps to post_failed
expect($this->user->wantsEmailFor(Type::AccountDisconnected))->toBeFalse();
});
test('wantsEmailFor defaults to true when no preferences exist', function () {
expect($this->user->wantsEmailFor('post_published'))->toBeTrue();
expect($this->user->wantsEmailFor('post_failed'))->toBeTrue();
expect($this->user->wantsEmailFor('account_disconnected'))->toBeTrue();
expect($this->user->wantsEmailFor(Type::PostPublished))->toBeTrue();
expect($this->user->wantsEmailFor(Type::PostFailed))->toBeTrue();
expect($this->user->wantsEmailFor(Type::AccountDisconnected))->toBeTrue();
});
test('send notification respects email preferences', function () {

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\User;
test('signup success page requires authentication', function () {
$response = $this->get(route('register.success'));
$response->assertRedirect(route('login'));
});
test('signup success page renders with default email provider', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('register.success'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('auth/SignupSuccess')
->where('authProvider', 'email')
);
});
test('signup success page renders with google provider from session', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->withSession(['auth_provider' => 'google'])
->get(route('register.success'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('auth/SignupSuccess')
->where('authProvider', 'google')
);
});

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -13,7 +14,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('bluesky connect page can be rendered', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -15,7 +16,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('facebook connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -14,7 +15,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('instagram connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -15,7 +16,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('linkedin connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -15,7 +16,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('linkedin page connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -13,7 +14,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('mastodon connect page can be rendered', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -14,7 +15,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('pinterest connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -13,7 +14,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('threads connect redirects to oauth', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -14,7 +15,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('tiktok connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -14,7 +15,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('x connect redirects to oauth provider', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -15,7 +16,7 @@
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
});
test('youtube connect redirects to oauth provider', function () {

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
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->user->workspaces()->attach($this->workspace->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('toggle active account to inactive', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'is_active' => true,
]);
$response = $this->actingAs($this->user)->put(route('app.accounts.toggle', $account));
$response->assertRedirect();
expect($account->fresh()->is_active)->toBeFalse();
});
test('toggle inactive account to active', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'is_active' => false,
]);
$response = $this->actingAs($this->user)->put(route('app.accounts.toggle', $account));
$response->assertRedirect();
expect($account->fresh()->is_active)->toBeTrue();
});
test('cannot toggle account from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create([
'workspace_id' => $otherWorkspace->id,
'is_active' => true,
]);
$response = $this->actingAs($this->user)->put(route('app.accounts.toggle', $account));
$response->assertForbidden();
});
test('member cannot toggle account', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'is_active' => true,
]);
$response = $this->actingAs($member)->put(route('app.accounts.toggle', $account));
$response->assertForbidden();
});

View file

@ -4,6 +4,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -11,6 +12,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->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -71,3 +73,24 @@
$response->assertForbidden();
});
// Member authorization tests
test('member cannot disconnect social account', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$this->actingAs($member)->delete(route('app.accounts.disconnect', $account))->assertForbidden();
});
test('member cannot toggle social account', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$this->actingAs($member)->put(route('app.accounts.toggle', $account))->assertForbidden();
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
@ -10,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' => 'owner']);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -24,7 +25,7 @@
test('workspaces index shows all workspaces for user', function () {
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $this->user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($this->user->id, ['role' => 'owner']);
$workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
}
$response = $this->actingAs($this->user)->get(route('app.workspaces.index'));
@ -139,7 +140,7 @@
test('switch workspace changes current workspace', function () {
$otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$otherWorkspace->members()->attach($this->user->id, ['role' => 'owner']);
$otherWorkspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$response = $this->actingAs($this->user)->post(route('app.workspaces.switch', $otherWorkspace));

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceHashtag;
@ -10,6 +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->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -167,3 +169,18 @@
->where('filters.search', '')
);
});
// Member authorization tests
test('member can create hashtag', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($member)->post(route('app.hashtags.store'), [
'name' => 'Test Group',
'hashtags' => '#test #hashtag',
]);
$response->assertRedirect();
expect($this->workspace->hashtags()->count())->toBe(1);
});

View file

@ -14,6 +14,7 @@
Mail::fake();
$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' => WorkspaceRole::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
@ -10,6 +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->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -176,3 +178,18 @@
->where('filters.search', '')
);
});
// Member authorization tests
test('member can create label', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($member)->post(route('app.labels.store'), [
'name' => 'Test Label',
'color' => '#FF0000',
]);
$response->assertRedirect();
expect($this->workspace->labels()->count())->toBe(1);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Broadcasting\PostChannel;
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
@ -10,7 +11,7 @@
test('post channel allows workspace member to join', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$post = Post::factory()->create(['workspace_id' => $workspace->id]);
$channel = new PostChannel;

View file

@ -20,6 +20,7 @@
test('owner can view workspace', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
expect($this->policy->view($user, $workspace))->toBeTrue();
});
@ -28,6 +29,7 @@
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($this->policy->view($member, $workspace))->toBeTrue();
@ -37,6 +39,7 @@
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
expect($this->policy->view($otherUser, $workspace))->toBeFalse();
});
@ -50,6 +53,7 @@
test('owner can update workspace', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
expect($this->policy->update($user, $workspace))->toBeTrue();
});
@ -58,6 +62,7 @@
$owner = User::factory()->create();
$admin = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
expect($this->policy->update($admin, $workspace))->toBeTrue();
@ -67,6 +72,7 @@
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($this->policy->update($member, $workspace))->toBeFalse();
@ -76,6 +82,7 @@
$owner = User::factory()->create();
$admin = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
expect($this->policy->delete($owner, $workspace))->toBeTrue();
@ -86,6 +93,7 @@
$owner = User::factory()->create();
$admin = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
expect($this->policy->restore($owner, $workspace))->toBeTrue();
@ -96,6 +104,7 @@
$owner = User::factory()->create();
$admin = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
expect($this->policy->forceDelete($owner, $workspace))->toBeTrue();
@ -107,6 +116,7 @@
$admin = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
@ -120,6 +130,7 @@
$admin = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
@ -133,9 +144,24 @@
$member = User::factory()->create();
$otherUser = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($this->policy->createPost($owner, $workspace))->toBeTrue();
expect($this->policy->createPost($member, $workspace))->toBeTrue();
expect($this->policy->createPost($otherUser, $workspace))->toBeFalse();
});
test('only owner can manage billing', function () {
$owner = User::factory()->create();
$admin = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($this->policy->manageBilling($owner, $workspace))->toBeTrue();
expect($this->policy->manageBilling($admin, $workspace))->toBeFalse();
expect($this->policy->manageBilling($member, $workspace))->toBeFalse();
});

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
@ -11,8 +12,8 @@
$workspace2 = Workspace::factory()->create(['user_id' => $user->id]);
// Add user as owner to both workspaces via pivot
$workspace1->members()->attach($user->id, ['role' => 'owner']);
$workspace2->members()->attach($user->id, ['role' => 'owner']);
$workspace1->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace2->members()->attach($user->id, ['role' => Role::Owner->value]);
expect($user->workspaces)->toHaveCount(2);
expect($user->workspaces->pluck('id')->toArray())->toContain($workspace1->id, $workspace2->id);
@ -22,7 +23,7 @@
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($member->id, ['role' => 'member']);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($member->workspaces)->toHaveCount(1);
expect($member->workspaces->first()->id)->toBe($workspace->id);
@ -50,7 +51,7 @@
test('user belongs to owned workspace', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
expect($user->belongsToWorkspace($workspace))->toBeTrue();
});
@ -59,7 +60,7 @@
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($member->id, ['role' => 'member']);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($member->belongsToWorkspace($workspace))->toBeTrue();
});
@ -77,7 +78,7 @@
$workspaces = Workspace::factory()->count(3)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
}
expect($user->ownedWorkspacesCount())->toBe(3);
@ -96,7 +97,7 @@
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
expect($user->canCreateWorkspace())->toBeFalse();
});
@ -108,7 +109,7 @@
$workspaces = Workspace::factory()->count(5)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
}
expect($user->canCreateWorkspace())->toBeTrue();
@ -127,7 +128,7 @@
]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
expect($user->canCreateWorkspace())->toBeTrue();
});
@ -147,7 +148,7 @@
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
}
expect($user->canCreateWorkspace())->toBeFalse();
@ -160,7 +161,7 @@
$user->incrementWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
expect($user->subscription(User::SUBSCRIPTION_NAME))->toBeNull();
});
test('increment workspace quantity is skipped in self-hosted mode', function () {
@ -171,7 +172,7 @@
// Should not throw any errors even without subscription
$user->incrementWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
expect($user->subscription(User::SUBSCRIPTION_NAME))->toBeNull();
});
test('decrement workspace quantity does nothing without subscription in SaaS mode', function () {
@ -181,7 +182,7 @@
$user->decrementWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
expect($user->subscription(User::SUBSCRIPTION_NAME))->toBeNull();
});
test('decrement workspace quantity is skipped in self-hosted mode', function () {
@ -192,7 +193,7 @@
// Should not throw any errors even without subscription
$user->decrementWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
expect($user->subscription(User::SUBSCRIPTION_NAME))->toBeNull();
});
test('sync workspace quantity does nothing without subscription in SaaS mode', function () {
@ -202,12 +203,12 @@
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
}
$user->syncWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
expect($user->subscription(User::SUBSCRIPTION_NAME))->toBeNull();
});
test('sync workspace quantity is skipped in self-hosted mode', function () {
@ -217,13 +218,13 @@
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
}
// Should not throw any errors even without subscription
$user->syncWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
expect($user->subscription(User::SUBSCRIPTION_NAME))->toBeNull();
});
test('sync workspace quantity does nothing with zero workspaces', function () {
@ -241,5 +242,5 @@
$user->syncWorkspaceQuantity();
// Quantity remains unchanged because there are no workspaces
expect($user->subscription('default')->quantity)->toBe(5);
expect($user->subscription(User::SUBSCRIPTION_NAME)->quantity)->toBe(5);
});