feat: add duplicate post functionality and migrate LinkedIn analytics to the /rest/ API.

This commit is contained in:
Paulo Castellano 2026-05-03 22:37:51 -03:00
parent 5658ea8c8c
commit 6f3bdb2caa
12 changed files with 177 additions and 35 deletions

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Actions\Post;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Clones a Post (and its enabled platform rows + label associations) into a
* fresh Draft. The new post is owned by the actor and unscheduled the user
* picks a new date in the editor.
*/
class DuplicatePost
{
public static function execute(Post $original, User $user): Post
{
return DB::transaction(function () use ($original, $user): Post {
$copy = $original->workspace->posts()->create([
'user_id' => $user->id,
'content' => $original->content,
'media' => $original->media,
'status' => PostStatus::Draft,
'scheduled_at' => null,
'published_at' => null,
]);
foreach ($original->postPlatforms as $platform) {
$copy->postPlatforms()->create([
'social_account_id' => $platform->social_account_id,
'platform' => $platform->platform,
'platform_name' => $platform->platform_name,
'platform_username' => $platform->platform_username,
'platform_avatar' => $platform->getRawOriginal('platform_avatar'),
'content_type' => $platform->content_type,
'enabled' => $platform->enabled,
'meta' => $platform->meta,
// Always reset platform-level status — never carry
// published/failed/publishing into the new draft.
'status' => PostPlatformStatus::Pending,
'platform_post_id' => null,
'platform_url' => null,
'error_message' => null,
'error_context' => null,
'published_at' => null,
]);
}
$copy->labels()->attach($original->labels->pluck('id'));
return $copy;
});
}
}

View file

@ -6,6 +6,7 @@
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\DuplicatePost;
use App\Actions\Post\SyncPostPlatforms;
use App\Actions\Post\UpdatePost;
use App\Enums\Post\Action as PostAction;
@ -371,4 +372,18 @@ public function destroy(Request $request, Post $post): RedirectResponse
return redirect()->route('app.posts.index');
}
public function duplicate(Request $request, Post $post): RedirectResponse
{
$this->authorize('duplicate', $post);
$post->load(['postPlatforms', 'labels']);
$copy = DuplicatePost::execute($post, $request->user());
session()->flash('flash.banner', __('posts.flash.duplicated'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.posts.edit', $copy);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
/**
* Authorize duplicating a post into the user's current workspace as a
* fresh draft. The post must live in the user's current workspace and
* the user must have permission to create posts there.
*/
public function duplicate(User $user, Post $post): bool
{
if ($post->workspace_id !== $user->current_workspace_id) {
return false;
}
return $user->can('createPost', $user->currentWorkspace);
}
}

View file

@ -24,7 +24,11 @@ class LinkedInPageAnalytics
public function __construct()
{
$this->baseUrl = config('trypost.platforms.linkedin-page.api').'/v2';
// Versioned API (`/rest/`) is the only one that honours the
// LinkedIn-Version header and the current analytics schemas.
// The legacy `/v2/` path rejects newer parameter formats with
// "Parameter 'timeIntervals' is invalid".
$this->baseUrl = config('trypost.platforms.linkedin-page.api').'/rest';
}
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
@ -85,9 +89,12 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$this->accessToken = $account->access_token;
$orgUrn = urlencode("urn:li:organization:{$account->platform_user_id}");
$startMs = $since->startOfDay()->getTimestampMs();
$endMs = $until->endOfDay()->getTimestampMs();
$orgUrn = "urn:li:organization:{$account->platform_user_id}";
// LinkedIn requires both endpoints of the timeRange to be at midnight
// UTC (00:00:00.000). endOfDay() produces 23:59:59.999 which the API
// silently rejects with "Parameter 'timeIntervals' is invalid".
$startMs = $since->copy()->utc()->startOfDay()->getTimestampMs();
$endMs = $until->copy()->utc()->startOfDay()->addDay()->getTimestampMs();
$timeInterval = "(timeRange:(start:{$startMs},end:{$endMs}),timeGranularityType:DAY)";
$metrics = [];
@ -109,12 +116,9 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
private function fetchPageStatistics(string $orgUrn, string $timeInterval): array
{
$org = rawurlencode($orgUrn);
$response = $this->getHttpClient()
->get("{$this->baseUrl}/organizationPageStatistics", [
'q' => 'organization',
'organization' => urldecode($orgUrn),
'timeIntervals' => $timeInterval,
]);
->get("{$this->baseUrl}/organizationPageStatistics?q=organization&organization={$org}&timeIntervals={$timeInterval}");
if ($response->failed()) {
Log::warning('LinkedIn page statistics fetch failed', [
@ -136,12 +140,9 @@ private function fetchPageStatistics(string $orgUrn, string $timeInterval): arra
private function fetchFollowerStatistics(string $orgUrn, string $timeInterval): array
{
$org = rawurlencode($orgUrn);
$response = $this->getHttpClient()
->get("{$this->baseUrl}/organizationalEntityFollowerStatistics", [
'q' => 'organizationalEntity',
'organizationalEntity' => urldecode($orgUrn),
'timeIntervals' => $timeInterval,
]);
->get("{$this->baseUrl}/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity={$org}&timeIntervals={$timeInterval}");
if ($response->failed()) {
Log::warning('LinkedIn follower statistics fetch failed', [
@ -175,12 +176,9 @@ private function fetchFollowerStatistics(string $orgUrn, string $timeInterval):
private function fetchShareStatistics(string $orgUrn, string $timeInterval): array
{
$org = rawurlencode($orgUrn);
$response = $this->getHttpClient()
->get("{$this->baseUrl}/organizationalEntityShareStatistics", [
'q' => 'organizationalEntity',
'organizationalEntity' => urldecode($orgUrn),
'timeIntervals' => $timeInterval,
]);
->get("{$this->baseUrl}/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity={$org}&timeIntervals={$timeInterval}");
if ($response->failed()) {
Log::warning('LinkedIn share statistics fetch failed', [

View file

@ -23,7 +23,10 @@
'actions' => [
'view' => 'View post',
'delete' => 'Delete post',
'delete' => 'Delete',
'duplicate' => 'Duplicate',
'copy_id' => 'Copy ID',
'copied' => 'ID copied to clipboard',
],
'form' => [
@ -450,6 +453,7 @@
'scheduled' => 'Post scheduled successfully!',
'publishing' => 'Post is being published! It may take a few minutes to process and appear on each platform.',
'deleted' => 'Post deleted successfully!',
'duplicated' => 'Post duplicated as a draft.',
'cannot_edit_published' => 'Published posts cannot be edited.',
'cannot_delete_published' => 'Published posts cannot be deleted.',
'connect_first' => 'Connect at least one social network before creating a post.',

View file

@ -23,7 +23,10 @@
'actions' => [
'view' => 'Ver post',
'delete' => 'Eliminar post',
'delete' => 'Eliminar',
'duplicate' => 'Duplicar',
'copy_id' => 'Copiar ID',
'copied' => 'ID copiado al portapapeles',
],
'form' => [
@ -450,6 +453,7 @@
'scheduled' => '¡Post programado correctamente!',
'publishing' => '¡El post se está publicando! Puede tardar unos minutos en procesarse y aparecer en cada plataforma.',
'deleted' => '¡Post eliminado correctamente!',
'duplicated' => 'Post duplicado como borrador.',
'cannot_edit_published' => 'Los posts publicados no se pueden editar.',
'cannot_delete_published' => 'Los posts publicados no se pueden eliminar.',
'connect_first' => 'Conecta al menos una red social antes de crear un post.',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,10 @@
'actions' => [
'view' => 'Ver post',
'delete' => 'Excluir post',
'delete' => 'Excluir',
'duplicate' => 'Duplicar',
'copy_id' => 'Copiar ID',
'copied' => 'ID copiado para a área de transferência',
],
'form' => [
@ -450,6 +453,7 @@
'scheduled' => 'Post agendado com sucesso!',
'publishing' => 'Post está sendo publicado! Pode levar alguns minutos para processar e aparecer em cada plataforma.',
'deleted' => 'Post excluído com sucesso!',
'duplicated' => 'Post duplicado como rascunho.',
'cannot_edit_published' => 'Posts publicados não podem ser editados.',
'cannot_delete_published' => 'Posts publicados não podem ser excluídos.',
'connect_first' => 'Conecte pelo menos uma rede social antes de criar um post.',

View file

@ -1,15 +1,22 @@
<script setup lang="ts">
import { Head, InfiniteScroll, Link, router } from '@inertiajs/vue3';
import { IconFileText, IconSearch, IconTrash } from '@tabler/icons-vue';
import { IconCopy, IconCopyPlus, IconDots, IconFileText, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { create as createPost, destroy as destroyPost, edit as editPost, index as postsIndex, show as showPost } from '@/actions/App/Http/Controllers/App/PostController';
import { create as createPost, destroy as destroyPost, duplicate as duplicatePost, edit as editPost, index as postsIndex, show as showPost } from '@/actions/App/Http/Controllers/App/PostController';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Table,
@ -26,6 +33,7 @@ import { getPostStatusConfig } from '@/composables/usePostStatus';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
import type { BreadcrumbItem } from '@/types';
interface SocialAccount {
@ -140,6 +148,12 @@ const handleDelete = (post: Post) => {
deleteModal.value?.open({ url: destroyPost.url(post.id) });
};
const handleDuplicate = (post: Post) => {
router.post(duplicatePost.url(post.id));
};
const handleCopyId = (post: Post) => copyToClipboard(post.id, trans('posts.actions.copied'));
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
</script>
@ -256,19 +270,38 @@ const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
{{ formatDateTime(post.scheduled_at ?? post.published_at) }}
</TableCell>
<TableCell class="text-right" @click.stop>
<Tooltip v-if="canEdit(post)">
<TooltipTrigger as-child>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click.stop="handleDelete(post)"
class="size-8 text-muted-foreground"
@click.stop
>
<IconTrash class="h-4 w-4" />
<IconDots class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.actions.delete') }}</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleDuplicate(post)">
<IconCopyPlus class="size-4" />
{{ $t('posts.actions.duplicate') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleCopyId(post)">
<IconCopy class="size-4" />
{{ $t('posts.actions.copy_id') }}
</DropdownMenuItem>
<template v-if="canEdit(post)">
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@click="handleDelete(post)"
>
<IconTrash class="size-4" />
{{ $t('posts.actions.delete') }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>

View file

@ -154,6 +154,7 @@
Route::get('posts/{post}/platforms/{postPlatform}/metrics', [PostController::class, 'platformMetrics'])->name('app.posts.platforms.metrics');
Route::put('posts/{post}', [PostController::class, 'update'])->name('app.posts.update');
Route::delete('posts/{post}', [PostController::class, 'destroy'])->name('app.posts.destroy');
Route::post('posts/{post}/duplicate', [PostController::class, 'duplicate'])->name('app.posts.duplicate');
// Post Templates
Route::get('post-templates', [PostTemplateController::class, 'index'])->name('app.post-templates.index');