feat: PostPlatform enum, failure email, DB indexes, rate limiting, tests
Publishing improvements: - Create PostPlatformStatus enum (Pending, Publishing, Published, Failed) - Update PostPlatform model, jobs, factories to use enum - Add PostPublishFailed email notification when post fails to publish - Maizzle template + blade for failure email with platform details - PublishPost job: add $tries=3, $backoff=30, failed() method - Fix broadcast event to serialize enum status value Security: - Add rate limiting (throttle:6,1) on social connect endpoints - Fix MediaController::reorder IDOR vulnerability - Fix Connect.vue broken import (storeStep2 -> storeConnect) - Fix UpdatePost data_get() consistency Database: - Add composite index on post_platforms (post_id, enabled) - Add index on post_platforms (social_account_id) Tests: - Add 3 tests for profile photo upload/delete - Add 2 tests for media reorder (including IDOR check) - Fix publish tests for PostPlatformStatus enum - Add Mail::fake() to publish tests Cleanup: - Remove unused AppHeader.vue and AppHeaderLayout.vue - Remove dead BillingController methods All 733 tests passing.
This commit is contained in:
parent
06e01797d1
commit
ceb7b92b74
22 changed files with 449 additions and 21 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\PostPlatform\Status as PostPlatformStatus;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
|
@ -35,7 +36,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
|
|||
'platform' => $account->platform->value,
|
||||
'content' => '',
|
||||
'content_type' => ContentType::defaultFor($account->platform),
|
||||
'status' => 'pending',
|
||||
'status' => PostPlatformStatus::Pending,
|
||||
'enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
|
|||
$updateData['content_type'] = data_get($platformData, 'content_type');
|
||||
}
|
||||
|
||||
if (isset($platformData['meta'])) {
|
||||
if (data_get($platformData, 'meta') !== null) {
|
||||
$postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first();
|
||||
$updateData['meta'] = array_merge($postPlatform->meta ?? [], $platformData['meta']);
|
||||
$updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta'));
|
||||
}
|
||||
|
||||
$post->postPlatforms()
|
||||
|
|
|
|||
13
app/Enums/PostPlatform/Status.php
Normal file
13
app/Enums/PostPlatform/Status.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\PostPlatform;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Publishing = 'publishing';
|
||||
case Published = 'published';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ public function broadcastWith(): array
|
|||
return [
|
||||
'post_platform' => [
|
||||
'id' => $this->postPlatform->id,
|
||||
'status' => $this->postPlatform->status,
|
||||
'status' => $this->postPlatform->status->value,
|
||||
'platform_url' => $this->postPlatform->platform_url,
|
||||
'error_message' => $this->postPlatform->error_message,
|
||||
'published_at' => $this->postPlatform->published_at?->toISOString(),
|
||||
|
|
|
|||
|
|
@ -92,8 +92,20 @@ public function reorder(Request $request): JsonResponse
|
|||
'media.*.order' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
$mediaIds = collect($request->input('media'))->pluck('id');
|
||||
|
||||
// Verify all media belongs to the current workspace
|
||||
$ownedCount = Media::whereIn('id', $mediaIds)
|
||||
->whereHasMorph('mediable', [PostPlatform::class], fn ($query) => $query->whereHas('post', fn ($q) => $q->where('workspace_id', $workspace->id))
|
||||
)->count();
|
||||
|
||||
if ($ownedCount !== $mediaIds->count()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
foreach ($request->input('media') as $item) {
|
||||
Media::where('id', $item['id'])->update(['order' => $item['order']]);
|
||||
Media::where('id', data_get($item, 'id'))->update(['order' => data_get($item, 'order')]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
use App\Models\Post;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PublishPost implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 30;
|
||||
|
||||
public function __construct(public Post $post) {}
|
||||
|
||||
public function handle(): void
|
||||
|
|
@ -22,4 +27,12 @@ public function handle(): void
|
|||
PublishToSocialPlatform::dispatch($postPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('PublishPost job failed', [
|
||||
'post_id' => $this->post->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\PostPlatform\Status as PostPlatformStatus;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Events\PostPlatformStatusUpdated;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Mail\PostPublishFailed;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Services\Social\BlueskyPublisher;
|
||||
use App\Services\Social\FacebookPublisher;
|
||||
|
|
@ -22,6 +25,7 @@
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class PublishToSocialPlatform implements ShouldQueue
|
||||
{
|
||||
|
|
@ -106,8 +110,8 @@ private function updatePostStatus(): void
|
|||
$enabledPlatforms = $post->postPlatforms->where('enabled', true);
|
||||
|
||||
$total = $enabledPlatforms->count();
|
||||
$publishedCount = $enabledPlatforms->where('status', 'published')->count();
|
||||
$failedCount = $enabledPlatforms->where('status', 'failed')->count();
|
||||
$publishedCount = $enabledPlatforms->where('status', PostPlatformStatus::Published)->count();
|
||||
$failedCount = $enabledPlatforms->where('status', PostPlatformStatus::Failed)->count();
|
||||
$finishedCount = $publishedCount + $failedCount;
|
||||
|
||||
// Only update post status when all platforms have finished
|
||||
|
|
@ -119,8 +123,19 @@ private function updatePostStatus(): void
|
|||
$post->markAsPublished();
|
||||
} elseif ($publishedCount > 0) {
|
||||
$post->markAsPartiallyPublished();
|
||||
$this->notifyOwner($post);
|
||||
} else {
|
||||
$post->markAsFailed();
|
||||
$this->notifyOwner($post);
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyOwner(Post $post): void
|
||||
{
|
||||
$owner = $post->workspace->owner;
|
||||
|
||||
if ($owner) {
|
||||
Mail::to($owner)->send(new PostPublishFailed($post));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
app/Mail/PostPublishFailed.php
Normal file
61
app/Mail/PostPublishFailed.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Enums\PostPlatform\Status;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostPublishFailed extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Post $post
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Your post failed to publish in {$this->post->workspace->name}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
$failedPlatforms = $this->post->postPlatforms()
|
||||
->with('socialAccount')
|
||||
->where('enabled', true)
|
||||
->get()
|
||||
->filter(fn ($pp) => $pp->status === Status::Failed)
|
||||
->map(fn ($pp) => [
|
||||
'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', data_get($pp, 'socialAccount.display_name', '')).')',
|
||||
'error' => $pp->error_message,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return new Content(
|
||||
view: 'mail.post-publish-failed',
|
||||
with: [
|
||||
'title' => 'Your post failed to publish',
|
||||
'previewText' => 'One or more platforms failed to publish your post.',
|
||||
'body' => "Your scheduled post in the {$this->post->workspace->name} workspace failed to publish on one or more platforms.",
|
||||
'failedPlatforms' => $failedPlatforms,
|
||||
'url' => route('app.posts.edit', $this->post),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\PostPlatform\Status;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Models\Traits\HasMedia;
|
||||
use Database\Factories\PostPlatformFactory;
|
||||
|
|
@ -39,6 +40,7 @@ protected function casts(): array
|
|||
'enabled' => 'boolean',
|
||||
'platform' => SocialPlatform::class,
|
||||
'content_type' => ContentType::class,
|
||||
'status' => Status::class,
|
||||
'published_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
|
@ -56,13 +58,13 @@ public function socialAccount(): BelongsTo
|
|||
|
||||
public function markAsPublishing(): void
|
||||
{
|
||||
$this->update(['status' => 'publishing']);
|
||||
$this->update(['status' => Status::Publishing]);
|
||||
}
|
||||
|
||||
public function markAsPublished(string $platformPostId, ?string $platformUrl = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'published',
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => $platformPostId,
|
||||
'platform_url' => $platformUrl,
|
||||
'published_at' => now(),
|
||||
|
|
@ -72,7 +74,7 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n
|
|||
public function markAsFailed(string $errorMessage): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'failed',
|
||||
'status' => Status::Failed,
|
||||
'error_message' => $errorMessage,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\PostPlatform\Status;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
|
|
@ -30,7 +31,7 @@ public function definition(): array
|
|||
'platform' => Platform::LinkedIn,
|
||||
'content' => $this->faker->paragraph(),
|
||||
'content_type' => ContentType::LinkedInPost,
|
||||
'status' => 'pending',
|
||||
'status' => Status::Pending,
|
||||
'meta' => [],
|
||||
];
|
||||
}
|
||||
|
|
@ -45,7 +46,7 @@ public function disabled(): static
|
|||
public function published(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => 'published',
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => $this->faker->uuid(),
|
||||
'platform_url' => $this->faker->url(),
|
||||
'published_at' => now(),
|
||||
|
|
@ -55,7 +56,7 @@ public function published(): static
|
|||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => 'failed',
|
||||
'status' => Status::Failed,
|
||||
'error_message' => 'Failed to publish',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('post_platforms', function (Blueprint $table) {
|
||||
$table->index(['post_id', 'enabled']);
|
||||
$table->index('social_account_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('post_platforms', function (Blueprint $table) {
|
||||
$table->dropIndex(['post_id', 'enabled']);
|
||||
$table->dropIndex(['social_account_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
1
lang/php_en.json
Normal file
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
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
1
lang/php_pt-BR.json
Normal file
File diff suppressed because one or more lines are too long
50
maizzle/templates/post-publish-failed.html
Normal file
50
maizzle/templates/post-publish-failed.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<x-main>
|
||||
<div class="bg-zinc-50 sm:px-4 font-sans">
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td class="w-[552px] max-w-full">
|
||||
<x-header />
|
||||
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
|
||||
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
|
||||
@{{ $title }}
|
||||
</h1>
|
||||
|
||||
<p class="m-0 leading-6">
|
||||
@{{ $body }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 p-4 bg-zinc-50 rounded">
|
||||
<p class="m-0 text-sm font-semibold text-zinc-900">
|
||||
Failed platforms:
|
||||
</p>
|
||||
<ul class="m-0 mt-2 pl-5 leading-6 text-sm">
|
||||
@foreach($failedPlatforms as $platform)
|
||||
<li>
|
||||
<strong>@{{ $platform['name'] }}</strong>
|
||||
@if($platform['error'])
|
||||
— @{{ $platform['error'] }}
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<x-button href="@{{ $url }}">
|
||||
View Post →
|
||||
</x-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<x-footer />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
|
|
@ -4,7 +4,7 @@ import { IconCheck } from '@tabler/icons-vue';
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { storeStep2 } from '@/actions/App/Http/Controllers/App/OnboardingController';
|
||||
import { storeConnect } from '@/actions/App/Http/Controllers/App/OnboardingController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ onUnmounted(() => window.removeEventListener('message', handleOAuthMessage));
|
|||
|
||||
const submit = () => {
|
||||
isSubmitting.value = true;
|
||||
router.post(storeStep2.url());
|
||||
router.post(storeConnect.url());
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
125
resources/views/mail/post-publish-failed.blade.php
Normal file
125
resources/views/mail/post-publish-failed.blade.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
@if(isset($title))
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
|
||||
<style>
|
||||
.hover-i-text-decoration-underline:hover {
|
||||
text-decoration: underline !important
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sm-my-8 {
|
||||
margin-top: 32px !important;
|
||||
margin-bottom: 32px !important
|
||||
}
|
||||
.sm-px-4 {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important
|
||||
}
|
||||
.sm-px-6 {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important
|
||||
}
|
||||
.sm-leading-8 {
|
||||
line-height: 32px !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
|
||||
@if(isset($previewText))
|
||||
<div style="display: none">
|
||||
{{ $previewText }}
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
@endif
|
||||
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
|
||||
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
|
||||
<a href="https://trypost.it" target="_blank">
|
||||
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #3f3f46; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
|
||||
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000001">
|
||||
{{ $title }}
|
||||
</h1>
|
||||
<p style="margin: 0; line-height: 24px">
|
||||
{{ $body }}
|
||||
</p>
|
||||
<div style="margin-top: 16px; border-radius: 4px; background-color: #fafafa; padding: 16px">
|
||||
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #18181b">
|
||||
Failed platforms:
|
||||
</p>
|
||||
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 14px; line-height: 24px">
|
||||
@foreach($failedPlatforms as $platform)
|
||||
<li>
|
||||
<strong>{{ $platform['name'] }}</strong>
|
||||
@if($platform['error'])
|
||||
— {{ $platform['error'] }}
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<div style="display: flex; align-items: center; justify-content: center">
|
||||
<div>
|
||||
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">View Post →</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
|
||||
<p style="margin: 0 0 8px">
|
||||
Open-source social media scheduling tool
|
||||
</p>
|
||||
@if(isset($unsubscribe_url))
|
||||
<p style="margin: 8px 0 0">
|
||||
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -56,7 +56,7 @@ function () {
|
|||
});
|
||||
|
||||
// Social Connect routes
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::middleware(['auth', 'verified', 'throttle:6,1'])->group(function () {
|
||||
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
|
||||
Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback');
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\Status as PlatformStatus;
|
||||
use App\Enums\SocialAccount\Status as AccountStatus;
|
||||
use App\Events\PostPlatformStatusUpdated;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->socialAccount = SocialAccount::factory()->linkedin()->create([
|
||||
|
|
@ -63,7 +65,7 @@
|
|||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
$this->postPlatform->refresh();
|
||||
expect($this->postPlatform->status)->toBe('published');
|
||||
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
|
||||
expect($this->postPlatform->platform_post_id)->toBe('post-123');
|
||||
expect($this->postPlatform->platform_url)->toBe('https://linkedin.com/post/123');
|
||||
});
|
||||
|
|
@ -79,7 +81,7 @@
|
|||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
$this->postPlatform->refresh();
|
||||
expect($this->postPlatform->status)->toBe('failed');
|
||||
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($this->postPlatform->error_message)->toBe('API Error');
|
||||
});
|
||||
|
||||
|
|
@ -97,7 +99,7 @@
|
|||
$this->postPlatform->refresh();
|
||||
$this->socialAccount->refresh();
|
||||
|
||||
expect($this->postPlatform->status)->toBe('failed');
|
||||
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($this->socialAccount->status)->toBe(AccountStatus::Disconnected);
|
||||
});
|
||||
|
||||
|
|
@ -175,7 +177,7 @@
|
|||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
$this->postPlatform->refresh();
|
||||
expect($this->postPlatform->status)->toBe('failed');
|
||||
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
|
||||
});
|
||||
|
||||
|
|
@ -194,6 +196,6 @@
|
|||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
$this->postPlatform->refresh();
|
||||
expect($this->postPlatform->status)->toBe('failed');
|
||||
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,3 +140,51 @@
|
|||
|
||||
expect(Media::where('mediable_id', $otherPostPlatform->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// Reorder tests
|
||||
test('reorder media updates order', function () {
|
||||
$media1 = $this->postPlatform->addMedia(UploadedFile::fake()->image('img1.jpg'), 'media');
|
||||
$media2 = $this->postPlatform->addMedia(UploadedFile::fake()->image('img2.jpg'), 'media');
|
||||
|
||||
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
|
||||
'media' => [
|
||||
['id' => $media1->id, 'order' => 1],
|
||||
['id' => $media2->id, 'order' => 0],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
expect($media1->refresh()->order)->toBe(1);
|
||||
expect($media2->refresh()->order)->toBe(0);
|
||||
});
|
||||
|
||||
test('reorder media rejects media from other workspace', function () {
|
||||
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
|
||||
$otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$otherPost = Post::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
'user_id' => $otherUser->id,
|
||||
]);
|
||||
|
||||
$otherAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
]);
|
||||
|
||||
$otherPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $otherPost->id,
|
||||
'social_account_id' => $otherAccount->id,
|
||||
]);
|
||||
|
||||
$otherMedia = $otherPlatform->addMedia(UploadedFile::fake()->image('img.jpg'), 'media');
|
||||
|
||||
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
|
||||
'media' => [
|
||||
['id' => $otherMedia->id, 'order' => 0],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceLabel;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
|
|
@ -233,6 +234,7 @@
|
|||
});
|
||||
|
||||
test('publish now updates scheduled_at to current time', function () {
|
||||
Mail::fake();
|
||||
$this->freezeTime();
|
||||
|
||||
$post = Post::factory()->create([
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
test('profile page is displayed', function () {
|
||||
$user = User::factory()->create();
|
||||
|
|
@ -176,3 +178,55 @@
|
|||
// Verify member's current_workspace_id is updated to the other workspace
|
||||
expect($member->fresh()->current_workspace_id)->toBe($otherWorkspace->id);
|
||||
});
|
||||
|
||||
test('user can upload profile photo', function () {
|
||||
Storage::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->post(route('app.profile.upload-photo'), [
|
||||
'photo' => UploadedFile::fake()->image('avatar.jpg', 200, 200),
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->has_photo)->toBeTrue();
|
||||
expect($user->photo_url)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('user cannot upload non-image file as photo', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->post(route('app.profile.upload-photo'), [
|
||||
'photo' => UploadedFile::fake()->create('document.pdf', 100),
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('photo');
|
||||
});
|
||||
|
||||
test('user can delete profile photo', function () {
|
||||
Storage::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Upload first
|
||||
$this->actingAs($user)->post(route('app.profile.upload-photo'), [
|
||||
'photo' => UploadedFile::fake()->image('avatar.jpg', 200, 200),
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->has_photo)->toBeTrue();
|
||||
|
||||
// Delete
|
||||
$response = $this->actingAs($user)->delete(route('app.profile.delete-photo'));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->has_photo)->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue