Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9c5104a
feat(timeline): foundation — activity module, panel-app rename, schema
danielhe4rt May 9, 2026
0849c6c
feat(timeline): update models with relations, HasReactions, HasMedia,…
danielhe4rt May 9, 2026
fc9dac8
feat(timeline): add domain actions and feed query
danielhe4rt May 9, 2026
05c164d
feat(timeline): add PublishModerationEntry, fix UUID schema
danielhe4rt May 9, 2026
0e0ae61
feat(timeline): add Livewire components, Blade views, TimelinePage
danielhe4rt May 9, 2026
50d84cb
fix(timeline): skip moderation timeline entry when no user can be res…
danielhe4rt May 9, 2026
a542e9b
feat(timeline): listen for ActionExecuted to publish moderation entries
danielhe4rt May 9, 2026
dd3ed50
refactor(timeline): convert ModerationAction → ModerationEvent via li…
danielhe4rt May 9, 2026
2ff37d2
refactor(timeline): extract DTOs for CreatePost and CreateReply actions
danielhe4rt May 9, 2026
9d28d04
feat(timeline): add inline composer at top of feed
danielhe4rt May 9, 2026
13f831f
refactor(timeline): use Filament MarkdownEditor in composer
danielhe4rt May 9, 2026
2afed94
refactor(timeline): clean composer — Textarea + FileUpload, no toolbar
danielhe4rt May 9, 2026
20b46e1
feat(timeline): Twitter-like image upload toggle in composer
danielhe4rt May 9, 2026
a66abc5
fix(timeline): use x-show on FileUpload for image toggle
danielhe4rt May 9, 2026
08c0b5e
fix(timeline): resolve full disk path for FileUpload images
danielhe4rt May 9, 2026
996c2c6
docs(timeline): add timeline usage guide for adding new entry types
danielhe4rt May 9, 2026
c3f6e1a
feat(timeline): add thread page with reply system
danielhe4rt May 9, 2026
70b1446
fix(timeline): responsive mobile layout for all components
danielhe4rt May 9, 2026
1244882
chore(timeline): remove legacy modal composer and unused message routes
danielhe4rt May 9, 2026
aa6d9fe
chore(timeline): remove prototype files
danielhe4rt May 9, 2026
cdd9739
fix(timeline): tenant isolation, DB transactions, content validation
danielhe4rt May 9, 2026
fbb1403
fix(timeline): address important issues from code review
danielhe4rt May 9, 2026
d32c161
fix(timeline): address low-priority issues from code review
danielhe4rt May 9, 2026
da2159c
feat(timeline): disable topbar on app panel
danielhe4rt May 9, 2026
bffd5a1
fix(timeline): add PHPDoc @property annotations for PHPStan
danielhe4rt May 9, 2026
d7d91b7
refactor(timeline): address PR review comments
danielhe4rt May 9, 2026
fa5f225
refactor(timeline): use getMorphClass() in tests and factory
danielhe4rt May 9, 2026
46ef816
fix(timeline): restore resolveIdentity for FK-constrained columns
danielhe4rt May 9, 2026
1674123
refactor(timeline): address Clinton's PR review comments
danielhe4rt May 10, 2026
167fae4
refactor(timeline): address CodeRabbit review feedback
danielhe4rt May 10, 2026
14f0494
refactor(timeline): use blank() helper for content validation
danielhe4rt May 10, 2026
a713dbe
update: deps
danielhe4rt May 16, 2026
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/database/factories/PostEntryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Database\Factories;

use He4rt\Activity\Timeline\Delegated\PostEntry;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<PostEntry> */
final class PostEntryFactory extends Factory
{
protected $model = PostEntry::class;

/** @return array<string, mixed> */
public function definition(): array
{
return [
'content' => fake()->sentences(3, true),
];
}
}
41 changes: 41 additions & 0 deletions app-modules/activity/database/factories/TimelineFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Database\Factories;

use He4rt\Activity\Timeline\Delegated\PostEntry;
use He4rt\Activity\Timeline\Timeline;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<Timeline> */
final class TimelineFactory extends Factory
{
protected $model = Timeline::class;

/** @return array<string, mixed> */
public function definition(): array
{
return [
'user_id' => User::factory(),
'tenant_id' => Tenant::factory(),
'postable_type' => (new PostEntry)->getMorphClass(),
'postable_id' => PostEntry::factory(),
'is_ignored' => false,
'pinned' => false,
'views' => 0,
];
}

public function pinned(): static
{
return $this->state(['pinned' => true]);
}

public function ignored(): static
{
return $this->state(['is_ignored' => true]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php
Comment thread
danielhe4rt marked this conversation as resolved.

declare(strict_types=1);

use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('activity_timeline', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignIdFor(User::class, 'user_id');
$table->foreignIdFor(Tenant::class, 'tenant_id');
$table->uuidMorphs('postable');
$table->foreignUuid('root_id')->nullable();
$table->foreignUuid('parent_id')->nullable();
$table->boolean('is_ignored')->default(false);
$table->boolean('pinned')->default(false);
$table->integer('views')->default(0);
$table->timestamps();

$table->index(['tenant_id', 'created_at'], 'activity_timeline_tenant_feed_index');
$table->index(['tenant_id', 'parent_id', 'is_ignored', 'created_at'], 'activity_timeline_feed_composite_index');
});
}

public function down(): void
{
Schema::dropIfExists('activity_timeline');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('activity_post_entries', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->text('content');
$table->timestamps();
$table->softDeletes();
});
}

public function down(): void
{
Schema::dropIfExists('activity_post_entries');
}
};
167 changes: 167 additions & 0 deletions app-modules/activity/docs/timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Timeline

The timeline is a social feed on the `/app` panel. It displays two kinds of entries: user-authored posts (`post_entry`) and system-generated moderation events (`moderation_event`). New entry types can be added without modifying existing code.

## Architecture

```
activity_timeline (polymorphic hub)
├── postable_type: "post_entry" → PostEntry model (user content)
├── postable_type: "moderation_event" → ModerationEvent model (system)
└── postable_type: "your_new_type" → YourModel (future)
```

The `Timeline` model uses a polymorphic `postable` relation (`postable_type` + `postable_id`). Each entry type is a separate model registered in the morph map. The feed query, Livewire components, and Blade views delegate rendering by `postable_type`.

### Key files

| Layer | File | Purpose |
| -------- | -------------------------------------------------------- | --------------------------------------- |
| Model | `src/Timeline/Timeline.php` | Hub model with `postable()` morphTo |
| Model | `src/Timeline/Delegated/PostEntry.php` | User post content + media |
| Model | `src/Moderation/Models/ModerationEvent.php` | Moderation event data |
| Action | `src/Timeline/Actions/CreatePost.php` | Creates PostEntry + Timeline |
| Action | `src/Timeline/Actions/CreateReply.php` | Creates reply (1-level flat) |
| Action | `src/Timeline/Actions/TogglePinPost.php` | Pin/unpin own posts |
| Action | `src/Timeline/Actions/PublishModerationEntry.php` | ModerationEvent → Timeline |
| Listener | `src/Timeline/Listeners/PublishModerationToTimeline.php` | ActionExecuted → ModerationEvent |
| Query | `src/Timeline/Queries/TimelineFeed.php` | Feed builder (tenant, ignored, replies) |
| DTO | `src/Timeline/DTOs/CreatePostDTO.php` | Input for CreatePost |
| DTO | `src/Timeline/DTOs/CreateReplyDTO.php` | Input for CreateReply |
| Provider | `ActivityServiceProvider.php` | MorphMap, observers, listeners |

### UI files (panel-app module)

| File | Purpose |
| ------------------------------------------------------ | -------------------------------------- |
| `Livewire/Timeline/Feed.php` | Feed with infinite scroll |
| `Livewire/Timeline/PostShow.php` | Individual post with pin |
| `Livewire/Timeline/Composer.php` | Inline post composer |
| `views/livewire/timeline/feed.blade.php` | Feed layout, routes by `postable_type` |
| `views/components/timeline/header.blade.php` | Post header (avatar, name, time) |
| `views/components/timeline/post-entry.blade.php` | Post content + images |
| `views/components/timeline/moderation-event.blade.php` | Moderation card |
| `views/components/timeline/engagement.blade.php` | Reply/reaction/view counts, pin |

## Adding a new entry type

### Step 1: Create the model

Create your model in the appropriate module. It needs a UUID primary key to match the `activity_timeline.postable_id` column.

```php
// app-modules/your-module/src/Models/BadgeAward.php

final class BadgeAward extends Model
{
use HasUuids;

protected $fillable = ['user_id', 'badge_name', 'awarded_at'];
}
```

### Step 2: Register in the morph map

Add the alias in `ActivityServiceProvider::boot()`:

```php
Relation::morphMap([
// existing...
'badge_award' => BadgeAward::class,
]);
```

### Step 3: Create the timeline entry

When the event happens (badge earned, level up, etc.), create a `Timeline` row pointing to your model:

```php
Timeline::query()->create([
'user_id' => $user->id,
'tenant_id' => $tenantId,
'postable_type' => 'badge_award',
'postable_id' => $badgeAward->id,
]);
```

> For cross-module events, use a listener (like `PublishModerationToTimeline` listens for `ActionExecuted`):

```php
// In ActivityServiceProvider::boot()
Event::listen(BadgeAwarded::class, [PublishBadgeToTimeline::class, 'handle']);
```

### Step 4: Create the Blade component

Create a Blade component at `panel-app/resources/views/components/timeline/badge-award.blade.php`:

```blade
@props (['timeline'])

@php
$award = $timeline->postable;
@endphp

<div class="rounded-xl border ...">
{{-- your card design --}}
</div>
```

System-generated entries (no user interaction) should be pure Blade like this. If the card needs interactivity (like, reply), wrap it in a Livewire component instead.

### Step 5: Register in the feed view

Add your type to `feed.blade.php`:

```blade
@if ($item->postable_type === 'moderation_event')
<x-panel-app::timeline.moderation-event :timeline="$item" />
@elseif ($item->postable_type === 'badge_award')
<x-panel-app::timeline.badge-award :timeline="$item" />
@else
<livewire:timeline-post-show ... />
@endif
```

That's it. Five steps: model, morph map, creation logic, Blade component, feed routing.

## Important constraints

### Threading

Threading is 1-level flat. All replies point to the root post:

```
root_id = original_post.id
parent_id = original_post.id (always equals root_id)
```

Replying to a reply flattens — both `root_id` and `parent_id` point to the original root, never to the intermediate reply. The `CreateReply` action enforces this.

### Tenant scoping

Every timeline entry has a `tenant_id`. The `TimelineFeed` query filters by tenant. When creating entries, always pass the correct tenant — entries without a tenant won't appear in any feed.

### Pinning

Users can pin one post per tenant. Pinning a new post unpins the previous one automatically. Only the post owner can pin. `TogglePinPost` enforces both rules.

### Moderation event flow

Two sources create `ModerationEvent` records:

1. **Discord ETL** — imports bans/kicks from Discord directly as `ModerationEvent`
2. **Web admin panel** — moderator judges a `ModerationCase`, executes action → `ActionExecuted` event → `PublishModerationToTimeline` listener creates a `ModerationEvent`

Both paths converge on the same model. The `ModerationEvent::created` observer then creates the timeline entry via `PublishModerationEntry`. Only `Ban` and `Kick` types are published — `Warn`, `Mute`, etc. are filtered out.

### Blade vs Livewire rendering

- **Interactive entries** (user posts with pin, replies, reactions) → Livewire component (`PostShow`)
- **System entries** (moderation events, future badges/milestones) → pure Blade component (no Livewire overhead)

Each Livewire component on the page costs ~2-4KB in the HTML snapshot. System events that have no interactivity should always be pure Blade.

### Feed query

`TimelineFeed` excludes replies (`whereNull('parent_id')`) and ignored entries (`is_ignored = false`). It orders by `created_at DESC` and uses `simplePaginate(15)` to avoid COUNT queries. The feed Livewire component uses `HasLoadMore` trait for infinite scroll, capped at 100 items.
16 changes: 0 additions & 16 deletions app-modules/activity/routes/message-routes.php
Original file line number Diff line number Diff line change
@@ -1,19 +1,3 @@
<?php

declare(strict_types=1);

use App\Http\Middleware\BotAuthentication;
use App\Http\Middleware\VerifyIfHasTenantProviderMiddleware;
use He4rt\Activity\Message\Http\Controllers\MessagesController;
use Illuminate\Support\Facades\Route;

Route::prefix('api')->middleware(['api', BotAuthentication::class, VerifyIfHasTenantProviderMiddleware::class])->group(function (): void {
Route::prefix('messages')->group(function (): void {
Route::post('/{provider}', [MessagesController::class, 'postMessage'])->name('messages.create');
});

Route::prefix('voices')->group(function (): void {
Route::post('/{provider}', [MessagesController::class, 'postVoiceMessage'])
->name('voices.create');
});
});
12 changes: 12 additions & 0 deletions app-modules/activity/src/ActivityServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@
namespace He4rt\Activity;

use He4rt\Activity\Message\Models\Message;
use He4rt\Activity\Moderation\Models\ModerationEvent;
use He4rt\Activity\Timeline\Delegated\PostEntry;
use He4rt\Activity\Timeline\Listeners\PublishModerationToTimeline;
use He4rt\Activity\Timeline\Timeline;
use He4rt\Activity\Voice\Models\Voice;
use He4rt\Moderation\Enforcement\ActionExecuted;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;

class ActivityServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/activity-tracking.php', 'activity-tracking');

}

public function boot(): void
Expand All @@ -23,6 +30,11 @@ public function boot(): void
Relation::morphMap([
'message' => Message::class,
'voice' => Voice::class,
'post_entry' => PostEntry::class,
'moderation_event' => ModerationEvent::class,
'timeline' => Timeline::class,
]);

Event::listen(ActionExecuted::class, [PublishModerationToTimeline::class, 'handle']);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
use Carbon\Carbon;
use He4rt\Activity\Message\Models\Message;
use He4rt\Activity\Moderation\Enums\ModerationType;
use He4rt\Activity\Timeline\Observers\ModerationEventObserver;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
Expand All @@ -28,6 +30,7 @@
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[ObservedBy(ModerationEventObserver::class)]
final class ModerationEvent extends Model
{
use HasUuids;
Expand Down
Loading