Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app-modules/activity/routes/api-mobile-routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

use He4rt\Activity\Timeline\Http\Controllers\Mobile\MobileTimelineController;
use Illuminate\Support\Facades\Route;

Route::prefix('api/mobile')
->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');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Timeline\Http\Controllers\Mobile;

use App\Http\Controllers\Controller;
use He4rt\Activity\Timeline\Actions\CreatePost;
use He4rt\Activity\Timeline\Actions\CreateReply;
use He4rt\Activity\Timeline\Actions\DeleteReply;
use He4rt\Activity\Timeline\DTOs\CreatePostDTO;
use He4rt\Activity\Timeline\DTOs\CreateReplyDTO;
use He4rt\Activity\Timeline\Http\Resources\TimelinePostResource;
use He4rt\Activity\Timeline\Timeline;
use He4rt\Identity\User\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use RuntimeException;

final class MobileTimelineController extends Controller
{
/**
* Listar timeline
*
* Retorna os posts raiz da timeline, mais recentes primeiro.
*/
public function index(): AnonymousResourceCollection
{
$posts = Timeline::query()
->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<int, string>
*/
private function storeImages(Request $request): array
{
/** @var list<UploadedFile> $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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Timeline\Http\Resources;

use He4rt\Activity\Timeline\Delegated\PostEntry;
use He4rt\Activity\Timeline\Timeline;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Spatie\MediaLibrary\MediaCollections\Models\Media;

/**
* @mixin Timeline
*/
final class TimelinePostResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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(),
];
}
}
12 changes: 12 additions & 0 deletions app-modules/activity/src/Timeline/Timeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +74,16 @@ protected static function newFactory(): TimelineFactory
return TimelineFactory::new();
}

/** @param Builder<self> $query */
#[Scope]
protected function feed(Builder $query): void
{
$query->where('is_ignored', operator: false)
->whereHas('user')
->whereNull('parent_id')
->latest();
}

/** @return array<string, mixed> */
protected function casts(): array
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

use He4rt\Activity\Timeline\Timeline;
use He4rt\Identity\User\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;

it('rejects unauthenticated requests', function (): void {
$this->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();
});
2 changes: 2 additions & 0 deletions docs/plans/2026-09-22-api-mobile-jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading