Merge branch 'main' into feature/capture-signup-utms

This commit is contained in:
Paulo Castellano 2026-05-04 19:02:49 -03:00 committed by GitHub
commit 1bf0bbc9ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 160 additions and 10 deletions

View file

@ -7,8 +7,10 @@
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\UpdatePost;
use App\Enums\Media\Type as MediaType;
use App\Enums\Post\Action as PostAction;
use App\Http\Requests\Api\Post\AttachMediaRequest;
use App\Http\Requests\Api\Post\AttachMediaFromUrlRequest;
use App\Http\Requests\Api\Post\StoreMediaRequest;
use App\Http\Requests\Api\Post\StorePostRequest;
use App\Http\Requests\Api\Post\UpdatePostRequest;
use App\Http\Resources\Api\PostMediaAttachResource;
@ -20,6 +22,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class PostController extends Controller
@ -83,7 +86,42 @@ public function destroy(Request $request, Post $post): JsonResponse
return response()->json(null, Response::HTTP_NO_CONTENT);
}
public function attachMedia(AttachMediaRequest $request, Post $post): PostMediaAttachResource
public function storeMedia(StoreMediaRequest $request, Post $post): PostResource
{
$this->authorize('update', $post);
$file = $request->file('media');
$type = MediaType::fromMime((string) $file->getMimeType());
if ($type === null || ! in_array($type, $post->allowedMediaTypes(), true)) {
throw ValidationException::withMessages([
'media' => 'This file type is not supported by the platforms enabled on the post.',
]);
}
if ($file->getSize() > $type->maxSizeInBytes()) {
throw ValidationException::withMessages([
'media' => 'File size exceeds the maximum allowed for this media type.',
]);
}
$media = $post->workspace->addMedia($file, 'assets');
$post->appendMedia([[
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
]]);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
return new PostResource($post);
}
public function attachMediaFromUrl(AttachMediaFromUrlRequest $request, Post $post): PostMediaAttachResource
{
$this->authorize('update', $post);

View file

@ -6,7 +6,7 @@
use Illuminate\Foundation\Http\FormRequest;
class AttachMediaRequest extends FormRequest
class AttachMediaFromUrlRequest extends FormRequest
{
public function authorize(): bool
{

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Post;
use App\Enums\Media\Type as MediaType;
use Illuminate\Foundation\Http\FormRequest;
class StoreMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
$allowedMimes = [
...MediaType::Image->allowedMimeTypes(),
...MediaType::Video->allowedMimeTypes(),
];
return [
// Use the largest per-type cap as the upper bound; per-type
// and per-post enforcement happens in the controller.
'media' => [
'required',
'file',
'max:'.MediaType::Video->maxSizeInKb(),
'mimetypes:'.implode(',', $allowedMimes),
],
];
}
}

View file

@ -9,7 +9,7 @@
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Wraps a Post with the result of an attach-media operation
* Wraps a Post with the result of an attach-media-from-url operation
* (counts + list of failed URLs).
*/
class PostMediaAttachResource extends JsonResource

View file

@ -11,7 +11,8 @@
/**
* Downloads public URLs and attaches them as media to a post used by
* the MCP `AttachMediaFromUrlTool` and the REST attach-media endpoint.
* the MCP `AttachMediaFromUrlTool` and the REST `attach-media-from-url`
* endpoint.
*
* URL syntax + DNS resolvability are validated at the request layer
* (`url:http,https`, `active_url`). Locking, intersection of accepted

View file

@ -18,7 +18,8 @@
Route::get('/posts/{post}', [PostController::class, 'show'])->name('api.posts.show');
Route::put('/posts/{post}', [PostController::class, 'update'])->name('api.posts.update');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('api.posts.destroy');
Route::post('/posts/{post}/media', [PostController::class, 'attachMedia'])->name('api.posts.attach-media');
Route::post('/posts/{post}/media', [PostController::class, 'storeMedia'])->name('api.posts.store-media');
Route::post('/posts/{post}/media/from-url', [PostController::class, 'attachMediaFromUrl'])->name('api.posts.attach-media-from-url');
Route::get('/posts/{post}/metrics', [PostController::class, 'metrics'])->name('api.posts.metrics');
Route::get('/posts/{post}/preview', [PostController::class, 'preview'])->name('api.posts.preview');

View file

@ -8,6 +8,7 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
@ -46,7 +47,7 @@
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $this->post), [
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => ['https://example.com/photo.png'],
])
->assertOk()
@ -63,7 +64,7 @@
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $this->post), [
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => ['https://example.com/missing.png'],
])
->assertOk()
@ -76,7 +77,7 @@
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $post), [
->postJson(route('api.posts.attach-media-from-url', $post), [
'urls' => ['https://example.com/photo.png'],
])
->assertNotFound();
@ -143,7 +144,78 @@
it('rejects attach media payload without urls', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $this->post), [])
->postJson(route('api.posts.attach-media-from-url', $this->post), [])
->assertUnprocessable()
->assertJsonValidationErrors(['urls']);
});
it('uploads a media file and attaches it to the post', function () {
$file = UploadedFile::fake()->createWithContent(
'photo.png',
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
->post(route('api.posts.store-media', $this->post), ['media' => $file])
->assertOk();
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
expect($this->post->fresh()->media)->toHaveCount(1);
});
it('rejects upload of an unsupported mime type', function () {
$file = UploadedFile::fake()->createWithContent('doc.txt', 'plain text content');
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
->post(route('api.posts.store-media', $this->post), ['media' => $file])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
});
it('rejects upload when the file type is not supported by enabled platforms', function () {
$tiktokAccount = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
]);
$tiktokOnlyPost = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
PostPlatform::factory()->tiktok()->create([
'post_id' => $tiktokOnlyPost->id,
'social_account_id' => $tiktokAccount->id,
'enabled' => true,
]);
$file = UploadedFile::fake()->createWithContent(
'photo.png',
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
->post(route('api.posts.store-media', $tiktokOnlyPost), ['media' => $file])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
});
it('cannot upload media to a post from another workspace', function () {
$other = Workspace::factory()->create();
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
$file = UploadedFile::fake()->createWithContent(
'photo.png',
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
->post(route('api.posts.store-media', $post), ['media' => $file])
->assertNotFound();
});
it('rejects upload without a media file', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store-media', $this->post), [])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
});