diff --git a/app-modules/activity/routes/api-mobile-routes.php b/app-modules/activity/routes/api-mobile-routes.php new file mode 100644 index 000000000..30c7f00e1 --- /dev/null +++ b/app-modules/activity/routes/api-mobile-routes.php @@ -0,0 +1,22 @@ +middleware(['api', 'auth:api']) + ->group(static function (): void { + Route::get('/timeline', [MobileTimelineController::class, 'index']) + ->name('mobile.timeline.index'); + + Route::post('/timeline', [MobileTimelineController::class, 'store']) + ->name('mobile.timeline.store'); + + Route::post('/timeline/{post}/replies', [MobileTimelineController::class, 'storeReply']) + ->name('mobile.timeline.replies.store'); + + Route::delete('/timeline/replies/{reply}', [MobileTimelineController::class, 'destroyReply']) + ->name('mobile.timeline.replies.destroy'); + }); diff --git a/app-modules/activity/src/Timeline/Http/Controllers/Mobile/MobileTimelineController.php b/app-modules/activity/src/Timeline/Http/Controllers/Mobile/MobileTimelineController.php new file mode 100644 index 000000000..14e0ed514 --- /dev/null +++ b/app-modules/activity/src/Timeline/Http/Controllers/Mobile/MobileTimelineController.php @@ -0,0 +1,136 @@ +feed() + ->with(['user', 'postable.media']) + ->withCount(['children', 'reactions']) + ->simplePaginate(20); + + return TimelinePostResource::collection($posts); + } + + /** + * Criar post + * + * Publica um novo post na timeline, com até 4 imagens opcionais. + */ + public function store(Request $request, CreatePost $createPost): JsonResponse + { + $request->validate([ + 'content' => ['required', 'string', 'max:5000'], + 'images' => ['array', 'max:4'], + 'images.*' => ['image', 'max:5120'], + ]); + + $post = $createPost->handle(new CreatePostDTO( + userId: $request->user()->id, + content: $request->string('content')->toString(), + images: $this->storeImages($request), + )); + + return $this->postResponse($post, Response::HTTP_CREATED); + } + + /** + * Responder post + * + * Cria uma resposta pro post (ou resposta) indicado — sempre fica + * pendurada no post raiz da thread, mesmo respondendo outra resposta. + */ + public function storeReply(Request $request, Timeline $post, CreateReply $createReply): JsonResponse + { + $request->validate([ + 'content' => ['required', 'string', 'max:5000'], + 'images' => ['array', 'max:4'], + 'images.*' => ['image', 'max:5120'], + ]); + + $reply = $createReply->handle(new CreateReplyDTO( + userId: $request->user()->id, + parentTimelineId: $post->id, + content: $request->string('content')->toString(), + images: $this->storeImages($request), + )); + + return $this->postResponse($reply, Response::HTTP_CREATED); + } + + /** + * Excluir resposta + * + * Remove uma resposta própria. Só o autor pode excluir, e só vale pra + * respostas — não para posts raiz. + */ + public function destroyReply(Request $request, Timeline $reply, DeleteReply $deleteReply): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + try { + $deleteReply->handle($user, $reply); + } catch (AuthorizationException $authorizationException) { + return response()->json(['message' => $authorizationException->getMessage()], Response::HTTP_FORBIDDEN); + } + + return response()->json(status: Response::HTTP_NO_CONTENT); + } + + /** + * @return array + */ + private function storeImages(Request $request): array + { + /** @var list $images */ + $images = $request->file('images', []); + + return array_map( + static function (UploadedFile $image): string { + $path = $image->store('timeline-uploads', 'public'); + throw_if($path === false, RuntimeException::class, 'Failed to store the uploaded image.'); + + return $path; + }, + $images, + ); + } + + private function postResponse(Timeline $post, int $status): JsonResponse + { + // create() não preenche defaults de banco (pinned, is_ignored, views) no model em memória. + $post->refresh()->load(['user', 'postable.media'])->loadCount(['children', 'reactions']); + + return new TimelinePostResource($post) + ->response() + ->setStatusCode($status); + } +} diff --git a/app-modules/activity/src/Timeline/Http/Resources/TimelinePostResource.php b/app-modules/activity/src/Timeline/Http/Resources/TimelinePostResource.php new file mode 100644 index 000000000..cbcc92166 --- /dev/null +++ b/app-modules/activity/src/Timeline/Http/Resources/TimelinePostResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + /** @var PostEntry $postEntry */ + $postEntry = $this->postable; + + return [ + 'id' => $this->id, + 'content' => $postEntry->content, + 'images' => $postEntry->getMedia('images') + ->map(static fn (Media $media): string => $media->getUrl()) + ->all(), + 'author' => [ + 'id' => $this->user->id, + 'username' => $this->user->username, + 'avatar_url' => $this->user->getFilamentAvatarUrl(), + ], + 'pinned' => $this->pinned, + 'root_id' => $this->root_id, + 'parent_id' => $this->parent_id, + 'replies_count' => $this->children_count ?? 0, + 'reactions_count' => $this->reactions_count ?? 0, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app-modules/activity/src/Timeline/Timeline.php b/app-modules/activity/src/Timeline/Timeline.php index 3340c17af..1b32b8511 100644 --- a/app-modules/activity/src/Timeline/Timeline.php +++ b/app-modules/activity/src/Timeline/Timeline.php @@ -8,7 +8,9 @@ use He4rt\Activity\Database\Factories\TimelineFactory; use He4rt\Activity\Reaction\Concerns\HasReactions; use He4rt\Identity\User\Models\User; +use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Attributes\Table; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -72,6 +74,16 @@ protected static function newFactory(): TimelineFactory return TimelineFactory::new(); } + /** @param Builder $query */ + #[Scope] + protected function feed(Builder $query): void + { + $query->where('is_ignored', operator: false) + ->whereHas('user') + ->whereNull('parent_id') + ->latest(); + } + /** @return array */ protected function casts(): array { diff --git a/app-modules/activity/tests/Feature/Http/MobileTimelineControllerTest.php b/app-modules/activity/tests/Feature/Http/MobileTimelineControllerTest.php new file mode 100644 index 000000000..2a586b6e7 --- /dev/null +++ b/app-modules/activity/tests/Feature/Http/MobileTimelineControllerTest.php @@ -0,0 +1,111 @@ +getJson(route('mobile.timeline.index')) + ->assertUnauthorized(); +}); + +it('lists root posts, newest first, with counts', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $older = Timeline::factory()->for($user)->create(['created_at' => now()->subDay()]); + $newer = Timeline::factory()->for($user)->create(['created_at' => now()]); + Timeline::factory()->for($user)->create(['parent_id' => $newer->id, 'root_id' => $newer->id]); + + $response = $this->getJson(route('mobile.timeline.index'), ['Authorization' => "Bearer {$token}"]) + ->assertOk(); + + $response->assertJsonPath('data.0.id', $newer->id) + ->assertJsonPath('data.0.replies_count', 1) + ->assertJsonPath('data.1.id', $older->id); +}); + +it('creates a post', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $this->postJson(route('mobile.timeline.store'), ['content' => 'Olá, comunidade He4rt!'], ['Authorization' => "Bearer {$token}"]) + ->assertCreated() + ->assertJsonPath('data.content', 'Olá, comunidade He4rt!') + ->assertJsonPath('data.author.id', $user->id) + ->assertJsonPath('data.parent_id', null) + ->assertJsonPath('data.pinned', false); + + $this->assertDatabaseCount('activity_timeline', 1); +}); + +it('creates a post with images', function (): void { + Storage::fake('public'); + + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $response = $this->postJson(route('mobile.timeline.store'), [ + 'content' => 'Com imagem', + 'images' => [UploadedFile::fake()->image('photo.jpg')], + ], ['Authorization' => "Bearer {$token}"]) + ->assertCreated(); + + expect($response->json('data.images'))->toHaveCount(1); +}); + +it('rejects an empty post', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $this->postJson(route('mobile.timeline.store'), ['content' => ''], ['Authorization' => "Bearer {$token}"]) + ->assertUnprocessable(); +}); + +it('creates a reply pinned to the root post', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + $post = Timeline::factory()->create(); + + $this->postJson(route('mobile.timeline.replies.store', ['post' => $post->getKey()]), ['content' => 'Concordo!'], ['Authorization' => "Bearer {$token}"]) + ->assertCreated() + ->assertJsonPath('data.content', 'Concordo!') + ->assertJsonPath('data.root_id', $post->id) + ->assertJsonPath('data.parent_id', $post->id); +}); + +it('deletes own reply', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + $post = Timeline::factory()->create(); + $reply = Timeline::factory()->for($user)->create(['parent_id' => $post->id, 'root_id' => $post->id]); + + $this->deleteJson(route('mobile.timeline.replies.destroy', ['reply' => $reply->getKey()]), [], ['Authorization' => "Bearer {$token}"]) + ->assertNoContent(); + + $this->assertDatabaseMissing('activity_timeline', ['id' => $reply->id]); +}); + +it('rejects deleting a reply from another user', function (): void { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $token = Auth::guard('api')->login($other); + $post = Timeline::factory()->create(); + $reply = Timeline::factory()->for($owner)->create(['parent_id' => $post->id, 'root_id' => $post->id]); + + $this->deleteJson(route('mobile.timeline.replies.destroy', ['reply' => $reply->getKey()]), [], ['Authorization' => "Bearer {$token}"]) + ->assertForbidden(); +}); + +it('rejects deleting a root post as if it were a reply', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + $post = Timeline::factory()->for($user)->create(); + + $this->deleteJson(route('mobile.timeline.replies.destroy', ['reply' => $post->getKey()]), [], ['Authorization' => "Bearer {$token}"]) + ->assertForbidden(); +}); diff --git a/docs/plans/2026-09-22-api-mobile-jwt.md b/docs/plans/2026-09-22-api-mobile-jwt.md index 22d3226c5..3be3fd2f0 100644 --- a/docs/plans/2026-09-22-api-mobile-jwt.md +++ b/docs/plans/2026-09-22-api-mobile-jwt.md @@ -98,6 +98,8 @@ Sem gap de domínio — é serialização pura em cima do que já existe. O corp **Fora do v1, decisão explícita**: reações (`withCount('reactions')` aparece no `Feed.php`) — o PRD não menciona reagir como escopo v1 do app; incluir a contagem na resposta é grátis, mas o endpoint de reagir fica pra depois se a issue não abrir esse escopo. +**Status: implementado** (`He4rt\Activity\Timeline\Http\Controllers\Mobile\MobileTimelineController` + `Http\Resources\TimelinePostResource`). Upload de imagens aceito via multipart (`images[]`), armazenado no mesmo diretório `timeline-uploads` que o Composer do painel usa. Rotas em `app-modules/activity/routes/api-mobile-routes.php`. + --- ## Feature 2 — Eventos