Merge branch 'main' into feature/capture-signup-utms
This commit is contained in:
commit
1bf0bbc9ef
7 changed files with 160 additions and 10 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachMediaRequest extends FormRequest
|
||||
class AttachMediaFromUrlRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
38
app/Http/Requests/Api/Post/StoreMediaRequest.php
Normal file
38
app/Http/Requests/Api/Post/StoreMediaRequest.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue