Skip to content

feat(timeline): social feed with posts, replies, and moderation events#223

Open
danielhe4rt wants to merge 31 commits into4.xfrom
feat/app-timeline
Open

feat(timeline): social feed with posts, replies, and moderation events#223
danielhe4rt wants to merge 31 commits into4.xfrom
feat/app-timeline

Conversation

@danielhe4rt
Copy link
Copy Markdown
Contributor

@danielhe4rt danielhe4rt commented May 9, 2026

Summary

Twitter-like social feed on the /app panel. Users can post (text + images), reply in threads, and see moderation events (bans/kicks) published automatically. Built on a polymorphic hub model extensible to new entry types.

  • Inline composer with MarkdownEditor + FileUpload (image toggle via Alpine x-show)
  • Dedicated thread page at /app/{tenant}/timeline/{id} with reply composer and paginated replies
  • Moderation integrationActionExecuted events from the moderation module automatically publish ban/kick entries to the feed
  • 1-level flat threading — reply-to-reply flattens to root, keeping the UI simple
  • Mobile responsive — all components scale down with responsive padding, hidden usernames, and icon-only buttons on small screens

Architecture

Timeline (polymorphic hub — activity_timeline)
├── postable_type: "post_entry"      → PostEntry (user content + images via Media Library)
├── postable_type: "moderation_event" → ModerationEvent (system-generated bans/kicks)
└── postable_type: "..."             → extensible to new types (see docs/timeline.md)

Core changes

Area What
Models Timeline (UUID, polymorphic postable, threading via root_id/parent_id, HasReactions, tenant-scoped), PostEntry (UUID, SoftDeletes, HasMedia)
Actions CreatePost, CreateReply (both atomic via DB::transaction), DeleteReply (ownership + tenant check), TogglePinPost (single pin per tenant, transactional), PublishModerationEntry
DTOs CreatePostDTO, CreateReplyDTO
Feed query TimelineFeed — filters by tenant, excludes replies and ignored entries, orders by created_at DESC
Cross-module PublishModerationToTimeline listener on ActionExecuted → converts ModerationAction into ModerationEvent → observer creates Timeline entry
Livewire Feed (infinite scroll), PostShow (post card), Composer (inline post form), ReplyComposer, ThreadReplies (paginated, delete-own)
Pages TimelinePage (dashboard), ThreadPage (/timeline/{record}, tenant-validated)
Security Tenant isolation on all queries, #[Locked] on IDs and $perPage, DB::transaction on writes, maxLength(5000) on content, wire:loading.attr="disabled" on submit buttons

File tree

app-modules/activity/
├── database/factories/
│   ├── PostEntryFactory.php
│   └── TimelineFactory.php
├── docs/
│   └── timeline.md                          # Guide for adding new entry types
├── src/
│   ├── ActivityServiceProvider.php          # MorphMap, observers, listeners
│   └── Timeline/
│       ├── Actions/
│       │   ├── CreatePost.php
│       │   ├── CreateReply.php
│       │   ├── DeleteReply.php
│       │   ├── PublishModerationEntry.php
│       │   └── TogglePinPost.php
│       ├── Delegated/
│       │   └── PostEntry.php
│       ├── DTOs/
│       │   ├── CreatePostDTO.php
│       │   └── CreateReplyDTO.php
│       ├── Listeners/
│       │   └── PublishModerationToTimeline.php
│       ├── Queries/
│       │   └── TimelineFeed.php
│       └── Timeline.php
└── tests/Unit/Timeline/
    ├── CreatePostTest.php
    ├── CreateReplyTest.php
    ├── DeleteReplyTest.php
    ├── PublishModerationEntryTest.php
    ├── TimelineFeedQueryTest.php
    ├── TimelineModelTest.php
    └── TogglePinPostTest.php

app-modules/panel-app/
├── resources/views/
│   ├── components/timeline/
│   │   ├── engagement.blade.php
│   │   ├── header.blade.php
│   │   ├── moderation-event.blade.php
│   │   └── post-entry.blade.php
│   ├── dashboard.blade.php
│   ├── livewire/timeline/
│   │   ├── composer.blade.php
│   │   ├── feed.blade.php
│   │   ├── post-show.blade.php
│   │   ├── reply-composer.blade.php
│   │   └── thread-replies.blade.php
│   └── pages/
│       └── thread.blade.php
├── src/
│   ├── Livewire/Timeline/
│   │   ├── Composer.php
│   │   ├── Concerns/HasLoadMore.php
│   │   ├── Feed.php
│   │   ├── PostShow.php
│   │   ├── ReplyComposer.php
│   │   └── ThreadReplies.php
│   ├── Pages/
│   │   ├── ThreadPage.php
│   │   └── TimelinePage.php
│   └── PanelAppServiceProvider.php
└── tests/Feature/Timeline/
    └── ThreadPageTest.php

app/Providers/Filament/
└── AppPanelProvider.php                     # ThreadPage + TimelinePage registration

database/migrations/
└── 2026_05_09_192029_add_composite_feed_index_to_activity_timeline.php

Test plan

Automated (32 tests passing)

  • CreatePostTest — creates post, rejects empty, handles long content, atomic rollback on failure
  • CreateReplyTest — reply to root, reply-to-reply flattens, atomic rollback
  • DeleteReplyTest — owner can delete, cannot delete others', cannot delete root posts
  • TogglePinPostTest — pin/unpin, only one pin per tenant, only owner
  • PublishModerationEntryTest — publishes ban/kick via observer, skips warn/mute, skips when no moderator
  • TimelineModelTest — relations, threading, morph map
  • TimelineFeedQueryTest — tenant scoping, excludes replies and ignored
  • ThreadPageTest — page renders, reply submission, chronological order, delete own, tenant isolation (3 cross-tenant tests)

Manual E2E checklist for reviewer

Feed

  • Navigate to /app/{tenant} — feed loads with infinite scroll
  • Write a post with text only → appears at top of feed
  • Write a post with text + images (click photo icon to toggle upload) → images render in grid
  • Write a post with 2000+ characters → saves correctly (no DB truncation)
  • Try submitting empty post → validation error
  • Pin your own post → "Fixado" badge appears, previous pin disappears
  • Scroll down → more posts load automatically (check network tab for simplePaginate)
  • Ban a user via admin panel → moderation card appears in the feed with red styling

Thread page

  • Click the comment icon on a post → navigates to /app/{tenant}/timeline/{id}
  • "Voltar para Timeline" link → returns to feed
  • Write a reply with text → appears in the replies list below
  • Write a reply with image → image renders in the reply
  • Reply to a post that already has replies → new reply appears at the bottom (chronological)
  • Delete your own reply (trash icon) → confirm dialog → reply disappears
  • Check you cannot see the trash icon on other users' replies
  • On a post with 4+ replies, check the feed card shows "Ver todas as X respostas →"

Tenant isolation (critical)

  • Copy a post UUID from Tenant A
  • Switch to Tenant B, navigate to /app/{tenant-b}/timeline/{uuid-from-tenant-a} → should 404

Mobile

  • Resize browser to 375px width — no horizontal scroll
  • Composer avatar hides, editor takes full width
  • Post header hides @username, pin shows icon only
  • Thread page replies have smaller avatars, hidden usernames
  • Moderation card inner margins shrink

Timeline Social Feed Feature

Description

Implements a Twitter-like social feed in the /app panel with posts, one-level threading, a dedicated thread page, and automatic publishing of moderation events. Features include polymorphic Timeline hub (activity_timeline), PostEntry models with media support, domain actions for creating/deleting posts and replies, tenant isolation, and 32 passing tests covering the complete feature.

References

This feature is part of PR #223. YuriSouzaDev approved with "LGTM" comment. Documentation provided in app-modules/activity/docs/timeline.md explaining the polymorphic timeline architecture, moderation event ingestion flow, and process for adding new timeline entry types.

Dependencies & Requirements

  • Updated Dependencies: laravel/framework (^13.7.0 → ^13.8.0), laravel/sanctum (^4.3.1 → ^4.3.2), spatie/laravel-medialibrary (^11.22.0 → ^11.22.1), pestphp/pest (dev, ^4.6.3 → ^4.7.0)
  • Configuration: New Filament panel "App" (FilamentPanel::App) with tenant support
  • Media Library: Requires S3 compatibility via addMediaFromDisk() for timeline post images
  • Database: New composite index on activity_timeline (tenant_id, parent_id, is_ignored, created_at)

Contributor Summary

Contributor Lines Added Lines Removed Files Changed
(PR Author) 2,543 99 56

Changes Summary

File Path Change Description
app-modules/activity/src/Timeline/Timeline.php New Eloquent model with UUID, soft deletes, polymorphic postable relation, threading (root_id/parent_id), reactions support
app-modules/activity/src/Timeline/Delegated/PostEntry.php PostEntry model with HasMedia, soft deletes, images media collection on public disk
app-modules/activity/src/Timeline/Actions/CreatePost.php Domain action creating post with images, validates content, wraps in DB::transaction
app-modules/activity/src/Timeline/Actions/CreateReply.php Domain action for replies with tenant scoping, flattens nested replies to root, transactional
app-modules/activity/src/Timeline/Actions/DeleteReply.php Deletes replies with authorization (owner check), force-deletes postable in transaction
app-modules/activity/src/Timeline/Actions/TogglePinPost.php Pins/unpins posts, ensures single pinned post per user/tenant
app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php Publishes Ban/Kick moderation events to timeline
app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php Listens to ActionExecuted events, creates ModerationEvent records with metadata
app-modules/activity/src/Timeline/Observers/ModerationEventObserver.php Observes ModerationEvent created event, triggers PublishModerationEntry
app-modules/activity/src/Timeline/Queries/TimelineFeed.php Tenant-scoped query excluding replies and ignored entries, ordered by created_at
app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php DTO capturing userId, tenantId, content, images for post creation
app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php DTO for replies with tenantId (added for scoping), parentTimelineId, content, images
app-modules/activity/database/factories/PostEntryFactory.php Factory generating PostEntry with faker content
app-modules/activity/database/factories/TimelineFactory.php Factory with pinned() and ignored() state helpers
app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php Creates activity_timeline table with morph relation, threading columns, feed indexes
app-modules/activity/database/migrations/2026_05_09_155946_create_activity_post_entries_table.php Creates activity_post_entries table with content, timestamps, soft deletes
app-modules/activity/src/ActivityServiceProvider.php Registers morph map entries and PublishModerationToTimeline listener
app-modules/activity/src/Moderation/Models/ModerationEvent.php Added ModerationEventObserver attribute
app-modules/panel-app/src/Livewire/Timeline/Composer.php Livewire component for post composer with Markdown editor, image uploads (max 4, public disk)
app-modules/panel-app/src/Livewire/Timeline/Feed.php Renders timeline feed with infinite scroll, listens to post/reply/delete events
app-modules/panel-app/src/Livewire/Timeline/PostShow.php Displays single post with toggle pin, loads user/postable/reactions/children
app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php Reply composer with locked timelineId, validates via form schema
app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php Displays paginated replies for thread, supports reply deletion with authorization
app-modules/panel-app/src/Livewire/Timeline/Concerns/HasLoadMore.php Trait with loadMore() pagination logic (increments perPage to max 100)
app-modules/panel-app/resources/views/livewire/timeline/composer.blade.php Form with Markdown editor, image upload toggle, submit button
app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php Renders feed items, empty state, infinite scroll loading indicator
app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php Post layout with header, post-entry/moderation-event conditional rendering, engagement, replies preview
app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php Reply form with initials avatar, Markdown editor, image upload
app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php Replies list, delete button (owner), markdown content, images grid, pagination
app-modules/panel-app/resources/views/components/timeline/engagement.blade.php Renders replies link, reactions display with counts, pin button (owner)
app-modules/panel-app/resources/views/components/timeline/header.blade.php User avatar, name, username, timestamp, pinned indicator
app-modules/panel-app/resources/views/components/timeline/post-entry.blade.php Markdown content, responsive images grid (1 or 2 columns)
app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php Moderation event display with type, subject, reason, reports/violation info
app-modules/panel-app/src/Pages/TimelinePage.php Dashboard page with Timeline navigation, full width, panel-app::dashboard view
app-modules/panel-app/src/Pages/ThreadPage.php Thread/detail page at timeline/{record}, validates root post exists in tenant
app-modules/panel-app/resources/views/pages/thread.blade.php Thread layout with back link, post show, reply composer, thread replies
app-modules/panel-app/resources/views/dashboard.blade.php Dashboard wrapper with timeline-feed Livewire component
app-modules/panel-app/src/PanelAppServiceProvider.php Registers views, translations, 5 timeline Livewire components
app-modules/panel-app/composer.json Updated namespace mappings PanelHub → PanelApp
app/Providers/Filament/AppPanelProvider.php New Filament panel configuration for App panel with tenant support, TimelinePage/ThreadPage routes
app/Enums/FilamentPanel.php Added App case, removed Partner/User/Guest/Event cases, removed moduleName() method
bootstrap/providers.php Registered AppPanelProvider instead of HubPanelProvider
app-modules/panel-hub/src/Pages/Dashboard.php Removed Dashboard page class
app-modules/panel-hub/src/PanelHubServiceProvider.php Removed provider (functionality moved to PanelApp)
app-modules/panel-hub/resources/views/dashboard.blade.php Removed page wrapper markup
app-modules/activity/routes/message-routes.php Removed API message routes
app-modules/activity/tests/Unit/Timeline/CreatePostTest.php 4 unit tests for post creation, atomicity, content validation
app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php 3 unit tests for reply creation, flattening, atomicity
app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php 3 unit tests for reply deletion, authorization, root post protection
app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php 3 unit tests for moderation event publishing (Ban/Kick/Warn scenarios)
app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php 4 unit tests for feed filtering (root only, excludes ignored/other tenants)
app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php 3 unit tests for Timeline model relationships and reactions
app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php 4 unit tests for pin toggle, single pin enforcement, authorization
app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php 7 feature tests for thread page, reply composer, ThreadReplies, tenant isolation
app-modules/activity/docs/timeline.md Documentation of polymorphic timeline, threading rules, moderation flow, component introduction process

danielhe4rt added 25 commits May 9, 2026 17:18
- Rename panel-hub → panel-app module
- Create activity_timeline and activity_post_entries tables
- Add tenant_id, drop is_reported, change content to text
- Add Timeline and PostEntry models with factories
- Register morphMap for post_entry and moderation_event
- Register prototype command in ActivityServiceProvider
- Rename HubPanelProvider → AppPanelProvider
- Add UI prototype variants for timeline feed
… morphMap

- Timeline: add tenant, postable, root/parent/children relations, HasReactions
- PostEntry: add InteractsWithMedia with images collection
- Register post_entry, moderation_event, timeline in morphMap
- Update factories with proper defaults
- Fix duplicate index in create migration
- Add 3 model unit tests
- CreatePost: creates PostEntry + Timeline entry, validates content
- CreateReply: 1-level thread flattening (reply-to-reply → root)
- TogglePinPost: single pin per user per tenant, authorization check
- TimelineFeed: tenant-scoped query, excludes replies and ignored posts
- 12 unit tests covering all actions and query
- PublishModerationEntry: only Ban/Kick create timeline entries
- Fix postable_id to uuidMorphs (ModerationEvent uses UUIDs)
- Add HasUuids to PostEntry, change id to uuid primary key
- Add migration to alter existing columns for UUID support
- Use ExternalIdentity.model_id for user resolution
- 3 unit tests (ban publishes, kick publishes, warn ignored)
- Feed component with HasLoadMore infinite scroll
- PostShow component with togglePin action
- Blade: header, post-entry, moderation-event (hero block), engagement
- TimelinePage with composer Action (MarkdownEditor + image upload)
- Register ModerationEvent::created observer in ActivityServiceProvider
- Register timeline-feed and timeline-post-show Livewire components
- Update dashboard view to render live feed
- Add PublishModerationToTimeline listener for cross-module integration
- Register listener via Event::listen(ActionExecuted::class) in ActivityServiceProvider
- Add moderation_action to morphMap
- Update moderation-event Blade to handle both ModerationEvent (Discord)
  and ModerationAction (web panel) with unified display
- Show report count and violation type from linked ModerationCase
…stener

ActionExecuted listener now creates a ModerationEvent (activity module)
from the ModerationAction (moderation module), instead of referencing
ModerationAction directly. The timeline only ever references
moderation_event as postable type.

- Listener resolves ExternalIdentity from User IDs for subject/moderator
- Enriches metadata with case_id, reports_count, violation_type, source
- Removed moderation_action from morphMap
- Blade component handles only ModerationEvent, reads enriched metadata
Twitter-like post field with avatar, auto-growing textarea, Ctrl+Enter
shortcut, and Postar button. Renders above the feed items.
Replace plain textarea with Filament HasSchemas + InteractsWithSchemas
pattern. Composer now uses MarkdownEditor with toolbar (bold, italic,
link, bulletList, orderedList) and proper form validation via getState().
Replace MarkdownEditor with Textarea (autosize, compact) and FileUpload
for images (up to 4). Matches the previous Twitter-like design with
avatar left, clean input, and footer bar with Ctrl+Enter hint + button.
FileUpload hidden by default, revealed via photo icon button in footer.
Alpine.js toggles visibility class on the upload field container.
Icon highlights when upload area is active.
Replace broken dynamic Tailwind class with x-show + x-cloak + x-transition
on the FileUpload field wrapper via extraFieldWrapperAttributes.
FileUpload stores relative paths (strings), not UploadedFile objects.
Use Storage::disk('public')->path() to resolve the full path before
passing to Spatie's addMedia().
Dedicated thread page at /app/{tenant}/timeline/{id} with reply composer,
reply listing, and delete-own-reply support. Comment icon in feed now
navigates to thread page. Replies only allowed on post_entry, not
moderation events.
Remove min-w-xl constraint, add responsive padding (px-3 sm:px-4),
hide @username and pin text on small screens, scale down avatars,
and reduce gaps for mobile viewports.
TimelinePage no longer needs the header action modal — replaced by
inline Composer. Also removes dead message-routes and fixes docs
blockquote formatting.
Prototypes served their purpose during design exploration — removing
the command, state helper, and UI variant views.
- Scope all Timeline queries by tenant_id in PostShow, ThreadPage,
  and ThreadReplies to prevent cross-tenant data access
- Wrap CreatePost and CreateReply in DB::transaction() to prevent
  orphaned PostEntry records on failure
- Add maxLength(5000) to Composer and ReplyComposer forms
- Remove dead prototype command registration from ActivityServiceProvider
- Lock $perPage with #[Locked] to prevent Livewire protocol tampering
- Paginate ThreadReplies with simplePaginate + HasLoadMore (was unbounded)
- Hard-delete PostEntry on reply deletion (was soft-deleted, creating orphans)
- Remove limit(3) from eager-load, use ->take(3) in Blade instead
- Only attribute moderation entries to moderator, never fall back to subject
- Add composite index (tenant_id, parent_id, is_ignored, created_at)
- Add wire:loading.attr="disabled" to composer submit buttons
- Rename delete event to timeline.reply-deleted (was misleading)
- Add @continue null check on $reply->postable in views
- Remove dead discoverPages call from PanelAppServiceProvider
- Remove views counter from engagement (never incremented)
- Wrap TogglePinPost in DB::transaction to prevent race condition
- Default TimelineFactory tenant_id to Tenant::factory()
Larastan infers user_id as int from foreignIdFor migration, but it's
a UUID string. Add explicit @Property types to resolve strict comparison
and undefined property errors.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

📝 Walkthrough

Walkthrough

This PR adds a polymorphic Timeline subsystem: migrations for activity_timeline and activity_post_entries; PostEntry and Timeline Eloquent models with media support and factories; DTOs and transactional actions for creating posts/replies, deleting replies, toggling pins, and publishing moderation events; a TimelineFeed query builder; listener and observer wiring for moderation events; Blade components and Livewire components for composer, feed, post display, replies, and thread page; Filament pages and a PanelAppServiceProvider; bootstrap provider registration and composer dependency bumps; and extensive unit and feature tests with documentation.

Possibly related issues

Possibly related PRs

  • he4rt/heartdevs.com#216: Related provider/page bootstrap changes; this PR migrates panel-hub artifacts to panel-app.
  • he4rt/heartdevs.com#187: Related polymorphic morph-map work; this PR adds morph aliases for post_entry, moderation_event, and timeline.
  • he4rt/heartdevs.com#205: Related moderation/timeline integration; this PR adds listener/observer and timeline publishing for moderation events.

Suggested reviewers

  • gvieira18
  • thalesmengue
  • Clintonrocha98
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main feature addition: a Twitter-like social feed with posts, replies, and moderation event integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor Author

@danielhe4rt danielhe4rt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nitpicks but looks good so far.

Comment thread app-modules/activity/src/Timeline/Actions/CreatePost.php Outdated
Comment thread app-modules/activity/src/Timeline/Actions/CreateReply.php Outdated
Comment thread app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php Outdated
Comment thread app-modules/activity/src/ActivityServiceProvider.php Outdated
Comment thread app-modules/panel-app/src/Livewire/Timeline/PostShow.php Outdated
Comment thread app-modules/panel-app/src/Pages/ThreadPage.php Outdated
Comment thread database/migrations/2026_05_09_172722_change_activity_post_entries_id_to_uuid.php Outdated
- Use getMorphClass() instead of hardcoded postable_type strings
- Use user_id directly in listener instead of resolving ExternalIdentity
- Extract ModerationEventObserver from inline callback
- Use filament()->getTenant()->getKey() helper consistently
- Remove redundant getTitle() from ThreadPage
- Consolidate 6 WIP migrations into 2 clean create-table migrations
  inside the activity module (removed from database/migrations/)
Replace all hardcoded 'post_entry' and 'moderation_event' strings
with (new Model)->getMorphClass() calls for consistency with the
morph map registered in ActivityServiceProvider.
The external_identity_id and moderator_identity_id columns have FK
constraints to external_identities table. Passing user_ids directly
caused QueryException in CI. Restored resolveIdentity lookup with
getMorphClass() for the model_type filter.
Comment thread app-modules/activity/src/ActivityServiceProvider.php Outdated
uses(RefreshDatabase::class);

test('creates a post entry and timeline record', function (): void {
$tenant = Tenant::factory()->create();

This comment was marked as resolved.

Comment thread app-modules/activity/tests/Unit/Timeline/CreatePostTest.php
Clintonrocha98
Clintonrocha98 previously approved these changes May 10, 2026
- Use #[ObservedBy] attribute on ModerationEvent model instead of
  ModerationEvent::observe() in ActivityServiceProvider
- Extract shared tenant/user setup into beforeEach() in CreatePostTest
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

♻️ Duplicate comments (1)
app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php (1)

11-14: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add defensive handling for short or empty user names.

This has the same issue as in composer.blade.php: the code assumes auth()->user()->name exists and has at least 2 characters.

🛡️ Proposed fix to handle edge cases
 {{
-    str(auth()->user()->name)
+    str(auth()->user()->name ?? 'U')
         ->substr(0, 2)
+        ->padRight(2, '?')
         ->upper()
 }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php`
around lines 11 - 14, The current snippet assumes auth()->user()->name exists
and has >=2 characters; update the template to defensively obtain the name
(e.g., use optional(auth()->user())->name ?? '' or the optional helper) then
trim it and take the first two characters only if present, falling back to a
safe placeholder (like '?' or the first character) before calling ->upper();
locate the expression that uses auth()->user()->name in the
reply-composer.blade.php and replace it with a guarded expression that handles
null/empty names and short names, ensuring the final value passed to ->substr(0,
2)->upper() is always a non-null string.
🧹 Nitpick comments (9)
app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php (1)

20-21: ⚡ Quick win

Consider adding foreign key constraints for referential integrity.

The root_id and parent_id columns are self-referential (pointing to other timeline entries) but lack explicit foreign key constraints. This could lead to orphaned replies if a parent or root post is deleted directly.

🔗 Proposed fix to add self-referential constraints
-            $table->foreignUuid('root_id')->nullable();
-            $table->foreignUuid('parent_id')->nullable();
+            $table->foreignUuid('root_id')->nullable()->constrained('activity_timeline')->nullOnDelete();
+            $table->foreignUuid('parent_id')->nullable()->constrained('activity_timeline')->nullOnDelete();

This ensures that when a parent/root is deleted, the references are automatically nullified, preventing orphaned records.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php`
around lines 20 - 21, The root_id and parent_id columns are defined as
foreignUuid but lack referential constraints; update the migration where
$table->foreignUuid('root_id')->nullable() and
$table->foreignUuid('parent_id')->nullable() to add self-referential foreign key
constraints pointing to the timeline table (the same table created in this
migration) and use ON DELETE SET NULL (e.g., via
constrained(...)->nullOnDelete() or
foreign(...)->references('id')->on('activity_timelines')->nullOnDelete()) so
deleting a parent/root nullifies these references and prevents orphaned records.
app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php (1)

33-60: ⚡ Quick win

Add a tenant-isolation assertion for unpin behavior.

Current coverage verifies “previous pinned post” logic, but not that unpinning is constrained to the same tenant for the same user.

Suggested additional test
+test('pinning in one tenant does not unpin another tenant pinned post', function (): void {
+    $user = User::factory()->create();
+    $tenantA = Tenant::factory()->create();
+    $tenantB = Tenant::factory()->create();
+
+    $entryA = PostEntry::factory()->create();
+    $entryB = PostEntry::factory()->create();
+
+    $pinnedInA = Timeline::factory()->for($user)->create([
+        'tenant_id' => $tenantA->id,
+        'postable_type' => (new PostEntry)->getMorphClass(),
+        'postable_id' => $entryA->id,
+        'pinned' => true,
+    ]);
+
+    $postInB = Timeline::factory()->for($user)->create([
+        'tenant_id' => $tenantB->id,
+        'postable_type' => (new PostEntry)->getMorphClass(),
+        'postable_id' => $entryB->id,
+    ]);
+
+    resolve(TogglePinPost::class)->handle($user, $postInB);
+
+    expect($pinnedInA->fresh()->pinned)->toBeTrue()
+        ->and($postInB->fresh()->pinned)->toBeTrue();
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php` around lines
33 - 60, The test needs an assertion that unpinning only affects posts in the
same tenant: create a second tenant and a pinned Timeline post for the same
$user but with that other tenant (use Tenant::factory() to create it, e.g.
$otherTenant) and a pinned $otherTenantPost, then call
TogglePinPost::handle($user, $secondPost) and assert
$otherTenantPost->fresh()->pinned is still true while
$firstPost->fresh()->pinned becomes false and $secondPost->fresh()->pinned
becomes true; reference the existing Timeline factory variables ($firstPost,
$secondPost) and the TogglePinPost::handle call when adding the new setup and
assertion.
app-modules/panel-app/src/Livewire/Timeline/PostShow.php (1)

51-51: ⚡ Quick win

Consider limiting children query for performance.

The children relationship is eager-loaded with latest() ordering but no limit. According to the AI summary, the view displays "up-to-3 children as replies". Consider adding ->limit(3) to prevent loading excessive data when a post has many replies.

⚡ Proposed performance optimization
-'children' => fn ($q) => $q->with('user', 'postable')->latest(),
+'children' => fn ($q) => $q->with('user', 'postable')->latest()->limit(3),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-app/src/Livewire/Timeline/PostShow.php` at line 51, The
children eager-load closure ('children' => fn ($q) => $q->with('user',
'postable')->latest()) can load an unbounded number of replies; update the
closure used in PostShow.php to constrain the result set (e.g., append
->limit(3) or ->take(3) after latest()) so only up to three child posts are
loaded, preserving the latest ordering and associated relations for performance.
app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php (2)

3-9: ⚡ Quick win

Consider adding type safety for postable.

The component assumes $timeline->postable is a ModerationEvent but doesn't validate this. If called with a different postable type, it could cause errors. Consider adding a type check or using a more specific component property.

🛡️ Proposed type safety improvement
 `@php`
     $event = $timeline->postable;
+    if (!$event instanceof \He4rt\Activity\Moderation\Models\ModerationEvent) {
+        throw new \InvalidArgumentException('Expected ModerationEvent postable');
+    }
     $moderatorVisible = $event->metadata['moderator_visible'] ?? false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php`
around lines 3 - 9, The template assumes $timeline->postable is a
ModerationEvent (used as $event) but doesn't validate it; add a runtime type
guard for $timeline->postable (e.g., check instanceof the
ModerationEvent/ModerationEvent model or appropriate class) before reading
metadata and computing $moderatorVisible, $reportsCount, $violationType and
$isBan, and provide a safe fallback (skip rendering or render an error/empty
state) when the check fails so the component never dereferences an unexpected
postable type.

59-59: 💤 Low value

Consider internationalization for hardcoded strings.

The view contains hardcoded Portuguese strings ("foi banido permanentemente", "removido da comunidade", "Motivo:", etc.) that would prevent localization. Consider using Laravel's __() translation helper for multi-language support.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php`
at line 59, The Portuguese strings in the Blade component (e.g. the conditional
text using $isBan: "foi banido permanentemente" vs "removido da comunidade", and
labels like "Motivo:") must be replaced with localization helpers; update
timeline/moderation-event.blade.php to use __('timeline.banned_permanently'),
__('timeline.removed'), __('timeline.reason') (or similar keys) and pass any
dynamic values via trans() or __() placeholders as needed, then add those keys
and translations to your resources/lang/* files so the view uses the translation
strings instead of hardcoded Portuguese.
app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php (1)

63-67: ⚡ Quick win

Optimize getMorphClass() call.

Creating a new User instance solely to call getMorphClass() is inefficient. Use the static ::class constant or a static method instead.

⚡ Proposed optimization
 return ExternalIdentity::query()
     ->where('model_id', $userId)
-    ->where('model_type', (new User)->getMorphClass())
+    ->where('model_type', User::class)
     ->where('tenant_id', $tenantId)
     ->value('id');

Or if you've configured a custom morph map:

+use Illuminate\Database\Eloquent\Relations\Relation;
+
-    ->where('model_type', (new User)->getMorphClass())
+    ->where('model_type', Relation::getMorphedModel('user') ?? User::class)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php`
around lines 63 - 67, The code creates a new User instance just to call
getMorphClass() which is unnecessary; update the ExternalIdentity query (the
where('model_type', (new User)->getMorphClass()) call) to use the class constant
instead (where('model_type', User::class)) or your app's configured morph map
value so you avoid instantiating User solely for the morph type.
app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php (1)

44-56: ⚡ Quick win

Verify public disk usage is appropriate for tenant-isolated timeline uploads.

The file upload configuration stores timeline images on the public disk at timeline-uploads. While the timeline feed itself is authenticated and tenant-scoped, the image files on the public disk lack filesystem-level tenant isolation. Confirm this approach is acceptable for your use case:

  • Uploaded images will be accessible via direct URL without authentication
  • In a multi-tenant system, if URL patterns are predictable, one tenant could potentially access another's images
  • For stronger tenant isolation, consider using a private disk with signed URLs instead
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php` around lines
44 - 56, The FileUpload field configuration FileUpload::make('images') currently
uses disk('public') and directory('timeline-uploads'), which exposes uploaded
timeline images via public URLs and lacks filesystem-level tenant isolation;
update this to use a tenant-aware storage approach—either switch to a
non-public/private disk (e.g., disk('tenant_private')) and serve images via
signed URLs, or include tenant scoping in the directory path (e.g.,
directory("tenants/{tenant_id}/timeline-uploads")) and ensure any retrieval
endpoints validate tenant access; change the disk('public') call and/or
directory('timeline-uploads') in ReplyComposer.php accordingly and wire up
signed URL generation where files will be served.
app-modules/panel-app/src/Livewire/Timeline/Feed.php (1)

21-24: 💤 Low value

Empty refresh() body is intentional but opaque — add a brief docblock.

In Livewire 3 a listener method that does nothing still forces a re-render of the component, which is what drives the feed refresh on timeline.* events. A one-line PHPDoc explaining the intent will spare future readers from “dead code” suspicion (and from accidentally deleting it).

📝 Suggested docblock
     #[On('timeline.post-created')]
     #[On('timeline.reply-created')]
     #[On('timeline.reply-deleted')]
+    /**
+     * No-op handler: receiving the event triggers a Livewire re-render,
+     * which re-runs render() and refreshes the feed query.
+     */
     public function refresh(): void {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-app/src/Livewire/Timeline/Feed.php` around lines 21 - 24,
Add a brief PHPDoc to the empty Livewire listener method refresh() that explains
it intentionally has no body because Livewire 3 will re-render the component
when the method is invoked; locate the method annotated with
#[On('timeline.post-created')], #[On('timeline.reply-created')],
#[On('timeline.reply-deleted')] in the Feed class and insert a one-line docblock
above public function refresh(): void {} describing that the empty method
triggers a component refresh and must be kept to avoid accidental removal.
app-modules/panel-app/src/Livewire/Timeline/Composer.php (1)

57-72: ⚡ Quick win

Consider surfacing success/failure feedback to the user.

After a successful post, the form is reset and an event is dispatched, but the composer gives no visible confirmation. A Filament\Notifications\Notification::make()->success()->send() on success (and a try/catch with a danger notification on failure) would meaningfully improve UX and make moderation/post-creation errors observable instead of silently swallowed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-app/src/Livewire/Timeline/Composer.php` around lines 57 -
72, Wrap the post() handler's CreatePost invocation in a try/catch, using
Filament\Notifications\Notification::make()->success()->send() after the
successful resolve(CreatePost::class)->handle(new CreatePostDTO(...)) and using
Notification::make()->danger()->send() in the catch with the exception message;
keep the existing form reset ($this->form->fill()) and event dispatch
($this->dispatch('timeline.post-created')) in the success path so they still run
only on success, and rethrow or handle errors as appropriate for your flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app-modules/activity/src/Timeline/Actions/CreatePost.php`:
- Around line 27-29: Replace the current pattern in CreatePost (and similarly in
CreateReply) where the code resolves a local filesystem path via
Storage::disk('public')->path($image) and calls
$postEntry->addMedia($path)->toMediaCollection('images'); with the disk-native
approach: remove the path conversion and call
$postEntry->addMediaFromDisk($image, 'public')->toMediaCollection('images') so
the code works with non-local disks (S3/R2); update the loop over $dto->images
accordingly and ensure you use addMediaFromDisk on the same model instance
($postEntry or reply model) and keep the collection name 'images'.

In `@app-modules/activity/src/Timeline/Actions/CreateReply.php`:
- Line 22: The parent timeline lookup in CreateReply.php uses
Timeline::query()->findOrFail($dto->parentTimelineId) without tenant scoping;
add tenant isolation by extending CreateReplyDTO to carry tenantId (e.g., public
int $tenantId) and change the lookup to query for the parent timeline with both
id and tenant_id (e.g., Timeline::query()->where('id',
$dto->parentTimelineId)->where('tenant_id', $dto->tenantId)->firstOrFail()), and
ensure any subsequent creation or association logic that uses the parent
timeline (the reply creation block around the code that uses $parentTimeline)
also uses $dto->tenantId so replies are created under the correct tenant
context.

In `@app-modules/activity/src/Timeline/Actions/DeleteReply.php`:
- Around line 13-23: In DeleteReply::handle, wrap the delete operations for
$reply and the optional $postable in a single database transaction so both
succeed or both roll back on error; use DB::transaction (or the database
facade/helper) to run the $reply->delete() and the conditional
$postable?->forceDelete() inside the closure, ensuring the exception from
forceDelete bubbles and triggers a rollback; keep the existing authorization
checks and make no other behavioral changes.

In `@app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php`:
- Around line 24-33: Validate the moderator identity before creating the
Timeline row: check $event->moderator exists and that
$event->moderator->model_type (or equivalent) denotes a User and that
$event->moderator->model_id is non-null and that the moderator's tenant matches
$event->tenant_id; if any check fails return null instead of proceeding to
Timeline::query()->create; update the logic around $userId and the create call
in PublishModerationEntry.php to enforce these tenant/model constraints.

In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php`:
- Around line 39-54: Wrap the ModerationEvent creation in a DB::transaction and
make it idempotent by using updateOrCreate keyed on action_id instead of create;
specifically, replace the ModerationEvent::query()->create(...) call in
PublishModerationToTimeline with DB::transaction(fn() =>
ModerationEvent::query()->updateOrCreate(['action_id' => $action->id], [
...attributes... ])) so the ModerationEvent and any observer-triggered Timeline
creation run in the same transaction, and add a DB unique index on action_id in
the migration to enforce uniqueness at the database level.

In
`@app-modules/panel-app/resources/views/components/timeline/engagement.blade.php`:
- Around line 35-37: The button that triggers the Livewire action togglePin is
missing an explicit type and may act as a form submitter; update the button
element (the one using wire:click="togglePin" and checking $timeline->pinned) to
include type="button" to prevent accidental form submission, leaving the
existing classes and wire:click handler unchanged.

In `@app-modules/panel-app/resources/views/components/timeline/header.blade.php`:
- Around line 23-26: The "Fixado" label is hidden on small screens which removes
accessible text for screen readers; update the pinned status block (the div
containing x-heroicon-s-map-pin and the span with class "hidden sm:inline") to
include an additional visually-hidden screen-reader-only element (e.g., a <span
class="sr-only">Fixado</span>) so the pinned state is announced on mobile while
preserving the existing visible span for larger screens.

In
`@app-modules/panel-app/resources/views/components/timeline/post-entry.blade.php`:
- Around line 11-14: The img tag using $media->getUrl() currently has an empty
alt attribute; update the img element to provide descriptive alt text (e.g. use
$media->alt, $media->getCustomProperty('alt'), $media->getDescription(), or fall
back to the post title or filename) so assistive tech can convey the image
meaning—modify the alt attribute on the <img src="{{ $media->getUrl() }}">
element to use the chosen descriptive value with a safe fallback.

In `@app-modules/panel-app/resources/views/livewire/timeline/composer.blade.php`:
- Around line 11-14: The avatar initials generation assumes auth()->user()->name
is non-empty and at least 2 chars; modify the blade expression that calls
str(auth()->user()->name)->substr(0, 2)->upper() to defensively handle
null/short names by first coalescing to an empty string or a fallback (e.g.,
auth()->user()->name ?? ''), trimming it, and then taking up to 2 characters (or
using conditional logic to use the first character or a placeholder) before
calling upper(); update the expression around str(...)->substr(0,2)->upper() to
use this safe fallback so it never errors on null or short names.

In
`@app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php`:
- Line 65: The image tag with alt="" in the thread-replies Blade template should
provide meaningful accessible text: update the img alt attribute to use the
media's metadata when available (e.g., use the reply/attachment alt/title field)
and fall back to a sensible default like "Image attached to reply"; if the image
is purely decorative, explicitly mark it as decorative by keeping alt="" and
adding aria-hidden="true" or role="presentation". Locate the img element in
thread-replies.blade.php and replace the static alt="" with a conditional that
uses the attachment's descriptive field (e.g., $attachment->alt or
$reply->media->alt) or the default string, or mark decorative images explicitly.

In `@app-modules/panel-app/src/Livewire/Timeline/Feed.php`:
- Around line 28-36: The query in Livewire\Timeline\Feed (using
TimelineFeed->builder()) is unnecessarily eager-loading the 'children' relation
(the entry 'children' => fn ($q) => $q->with('user', 'postable')->latest()) even
though child posts are loaded separately by the timeline-post-show component;
remove that 'children' clause from the with([...]) call so the builder only
eager-loads 'user' and 'postable' (leave the withCount('children','reactions')
intact) and let the child component handle its own limited reply queries.

In `@app-modules/panel-app/src/Pages/ThreadPage.php`:
- Around line 25-47: mount() currently runs an exists() query and getTimeline()
re-runs the same query; consolidate by loading the full Timeline once in mount()
(use Timeline::query()->where('id', $record)->where('tenant_id',
filament()->getTenant()->getKey())->whereNull('parent_id')->with(['user','postable','reactions'])->withCount('children','reactions')->firstOrFail())
and store it on the component (e.g. $this->timeline) so subsequent calls are
free; then change getTimeline() to simply return the memoized $this->timeline
(or lazy-load it if absent) to preserve behavior and avoid duplicate DB
round-trips.

---

Duplicate comments:
In
`@app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php`:
- Around line 11-14: The current snippet assumes auth()->user()->name exists and
has >=2 characters; update the template to defensively obtain the name (e.g.,
use optional(auth()->user())->name ?? '' or the optional helper) then trim it
and take the first two characters only if present, falling back to a safe
placeholder (like '?' or the first character) before calling ->upper(); locate
the expression that uses auth()->user()->name in the reply-composer.blade.php
and replace it with a guarded expression that handles null/empty names and short
names, ensuring the final value passed to ->substr(0, 2)->upper() is always a
non-null string.

---

Nitpick comments:
In
`@app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php`:
- Around line 20-21: The root_id and parent_id columns are defined as
foreignUuid but lack referential constraints; update the migration where
$table->foreignUuid('root_id')->nullable() and
$table->foreignUuid('parent_id')->nullable() to add self-referential foreign key
constraints pointing to the timeline table (the same table created in this
migration) and use ON DELETE SET NULL (e.g., via
constrained(...)->nullOnDelete() or
foreign(...)->references('id')->on('activity_timelines')->nullOnDelete()) so
deleting a parent/root nullifies these references and prevents orphaned records.

In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php`:
- Around line 63-67: The code creates a new User instance just to call
getMorphClass() which is unnecessary; update the ExternalIdentity query (the
where('model_type', (new User)->getMorphClass()) call) to use the class constant
instead (where('model_type', User::class)) or your app's configured morph map
value so you avoid instantiating User solely for the morph type.

In `@app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php`:
- Around line 33-60: The test needs an assertion that unpinning only affects
posts in the same tenant: create a second tenant and a pinned Timeline post for
the same $user but with that other tenant (use Tenant::factory() to create it,
e.g. $otherTenant) and a pinned $otherTenantPost, then call
TogglePinPost::handle($user, $secondPost) and assert
$otherTenantPost->fresh()->pinned is still true while
$firstPost->fresh()->pinned becomes false and $secondPost->fresh()->pinned
becomes true; reference the existing Timeline factory variables ($firstPost,
$secondPost) and the TogglePinPost::handle call when adding the new setup and
assertion.

In
`@app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php`:
- Around line 3-9: The template assumes $timeline->postable is a ModerationEvent
(used as $event) but doesn't validate it; add a runtime type guard for
$timeline->postable (e.g., check instanceof the ModerationEvent/ModerationEvent
model or appropriate class) before reading metadata and computing
$moderatorVisible, $reportsCount, $violationType and $isBan, and provide a safe
fallback (skip rendering or render an error/empty state) when the check fails so
the component never dereferences an unexpected postable type.
- Line 59: The Portuguese strings in the Blade component (e.g. the conditional
text using $isBan: "foi banido permanentemente" vs "removido da comunidade", and
labels like "Motivo:") must be replaced with localization helpers; update
timeline/moderation-event.blade.php to use __('timeline.banned_permanently'),
__('timeline.removed'), __('timeline.reason') (or similar keys) and pass any
dynamic values via trans() or __() placeholders as needed, then add those keys
and translations to your resources/lang/* files so the view uses the translation
strings instead of hardcoded Portuguese.

In `@app-modules/panel-app/src/Livewire/Timeline/Composer.php`:
- Around line 57-72: Wrap the post() handler's CreatePost invocation in a
try/catch, using Filament\Notifications\Notification::make()->success()->send()
after the successful resolve(CreatePost::class)->handle(new CreatePostDTO(...))
and using Notification::make()->danger()->send() in the catch with the exception
message; keep the existing form reset ($this->form->fill()) and event dispatch
($this->dispatch('timeline.post-created')) in the success path so they still run
only on success, and rethrow or handle errors as appropriate for your flow.

In `@app-modules/panel-app/src/Livewire/Timeline/Feed.php`:
- Around line 21-24: Add a brief PHPDoc to the empty Livewire listener method
refresh() that explains it intentionally has no body because Livewire 3 will
re-render the component when the method is invoked; locate the method annotated
with #[On('timeline.post-created')], #[On('timeline.reply-created')],
#[On('timeline.reply-deleted')] in the Feed class and insert a one-line docblock
above public function refresh(): void {} describing that the empty method
triggers a component refresh and must be kept to avoid accidental removal.

In `@app-modules/panel-app/src/Livewire/Timeline/PostShow.php`:
- Line 51: The children eager-load closure ('children' => fn ($q) =>
$q->with('user', 'postable')->latest()) can load an unbounded number of replies;
update the closure used in PostShow.php to constrain the result set (e.g.,
append ->limit(3) or ->take(3) after latest()) so only up to three child posts
are loaded, preserving the latest ordering and associated relations for
performance.

In `@app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php`:
- Around line 44-56: The FileUpload field configuration
FileUpload::make('images') currently uses disk('public') and
directory('timeline-uploads'), which exposes uploaded timeline images via public
URLs and lacks filesystem-level tenant isolation; update this to use a
tenant-aware storage approach—either switch to a non-public/private disk (e.g.,
disk('tenant_private')) and serve images via signed URLs, or include tenant
scoping in the directory path (e.g.,
directory("tenants/{tenant_id}/timeline-uploads")) and ensure any retrieval
endpoints validate tenant access; change the disk('public') call and/or
directory('timeline-uploads') in ReplyComposer.php accordingly and wire up
signed URL generation where files will be served.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 8e46c28b-2a97-498b-afdb-26d88a5a97b5

📥 Commits

Reviewing files that changed from the base of the PR and between 0c624a7 and 9a03c55.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (58)
  • app-modules/activity/database/factories/PostEntryFactory.php
  • app-modules/activity/database/factories/TimelineFactory.php
  • app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php
  • app-modules/activity/database/migrations/2026_05_09_155946_create_activity_post_entries_table.php
  • app-modules/activity/docs/timeline.md
  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/src/ActivityServiceProvider.php
  • app-modules/activity/src/Moderation/Models/ModerationEvent.php
  • app-modules/activity/src/Timeline/Actions/CreatePost.php
  • app-modules/activity/src/Timeline/Actions/CreateReply.php
  • app-modules/activity/src/Timeline/Actions/DeleteReply.php
  • app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php
  • app-modules/activity/src/Timeline/Actions/TogglePinPost.php
  • app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php
  • app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
  • app-modules/activity/src/Timeline/Delegated/PostEntry.php
  • app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php
  • app-modules/activity/src/Timeline/Observers/ModerationEventObserver.php
  • app-modules/activity/src/Timeline/Queries/TimelineFeed.php
  • app-modules/activity/src/Timeline/Timeline.php
  • app-modules/activity/tests/Unit/Timeline/CreatePostTest.php
  • app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php
  • app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php
  • app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php
  • app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php
  • app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php
  • app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php
  • app-modules/panel-app/.gitignore
  • app-modules/panel-app/composer.json
  • app-modules/panel-app/config/panel-admin.php
  • app-modules/panel-app/resources/views/components/timeline/engagement.blade.php
  • app-modules/panel-app/resources/views/components/timeline/header.blade.php
  • app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php
  • app-modules/panel-app/resources/views/components/timeline/post-entry.blade.php
  • app-modules/panel-app/resources/views/dashboard.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/composer.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php
  • app-modules/panel-app/resources/views/pages/thread.blade.php
  • app-modules/panel-app/src/Livewire/Timeline/Composer.php
  • app-modules/panel-app/src/Livewire/Timeline/Concerns/HasLoadMore.php
  • app-modules/panel-app/src/Livewire/Timeline/Feed.php
  • app-modules/panel-app/src/Livewire/Timeline/PostShow.php
  • app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
  • app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php
  • app-modules/panel-app/src/Pages/ThreadPage.php
  • app-modules/panel-app/src/Pages/TimelinePage.php
  • app-modules/panel-app/src/PanelAppServiceProvider.php
  • app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php
  • app-modules/panel-hub/resources/views/dashboard.blade.php
  • app-modules/panel-hub/src/Pages/Dashboard.php
  • app-modules/panel-hub/src/PanelHubServiceProvider.php
  • app/Enums/FilamentPanel.php
  • app/Providers/Filament/AppPanelProvider.php
  • bootstrap/providers.php
  • composer.json
💤 Files with no reviewable changes (4)
  • app-modules/panel-hub/resources/views/dashboard.blade.php
  • app-modules/panel-hub/src/PanelHubServiceProvider.php
  • app-modules/activity/routes/message-routes.php
  • app-modules/panel-hub/src/Pages/Dashboard.php

Comment thread app-modules/activity/src/Timeline/Actions/CreatePost.php Outdated
Comment thread app-modules/activity/src/Timeline/Actions/CreateReply.php Outdated
Comment thread app-modules/activity/src/Timeline/Actions/DeleteReply.php
Comment on lines +24 to +33
$userId = $event->moderator?->model_id;

if ($userId === null) {
return null;
}

return Timeline::query()->create([
'user_id' => $userId,
'tenant_id' => $event->tenant_id,
'postable_type' => $event->getMorphClass(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce moderator identity tenant/model constraints before creating timeline rows.

At Line 24, the action trusts moderator->model_id without validating that the moderator identity belongs to the same tenant and represents a User. This can write misattributed timeline entries in multi-tenant scenarios.

🔧 Proposed fix
 use He4rt\Activity\Moderation\Enums\ModerationType;
 use He4rt\Activity\Moderation\Models\ModerationEvent;
 use He4rt\Activity\Timeline\Timeline;
+use He4rt\Identity\User\Models\User;
@@
-        $userId = $event->moderator?->model_id;
-
-        if ($userId === null) {
+        $moderator = $event->moderator;
+        if (
+            $moderator === null
+            || $moderator->tenant_id !== $event->tenant_id
+            || $moderator->model_type !== (new User)->getMorphClass()
+            || $moderator->model_id === null
+        ) {
             return null;
         }
 
         return Timeline::query()->create([
-            'user_id' => $userId,
+            'user_id' => $moderator->model_id,
             'tenant_id' => $event->tenant_id,
             'postable_type' => $event->getMorphClass(),
             'postable_id' => $event->id,
         ]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php` around
lines 24 - 33, Validate the moderator identity before creating the Timeline row:
check $event->moderator exists and that $event->moderator->model_type (or
equivalent) denotes a User and that $event->moderator->model_id is non-null and
that the moderator's tenant matches $event->tenant_id; if any check fails return
null instead of proceeding to Timeline::query()->create; update the logic around
$userId and the create call in PublishModerationEntry.php to enforce these
tenant/model constraints.

Comment on lines +39 to +54
ModerationEvent::query()->create([
'tenant_id' => $tenantId,
'external_identity_id' => $subjectIdentityId,
'moderator_identity_id' => $moderatorIdentityId,
'type' => ModerationType::from($action->action_type->value),
'reason' => $action->reason ?? $case?->content_snapshot['text'] ?? null,
'metadata' => [
'source' => 'web_panel',
'case_id' => $case?->id,
'moderator_visible' => true,
'action_id' => $action->id,
'reports_count' => $case?->reports()?->count() ?? 0,
'violation_type' => $case?->violation_type?->value,
],
'occurred_at' => $action->created_at ?? now(),
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Consider adding transaction and duplicate prevention.

The ModerationEvent creation lacks:

  1. Transaction wrapping - If the subsequent Timeline creation (via observer) fails, this could leave orphaned ModerationEvent records
  2. Duplicate prevention - If the event fires multiple times or is retried, multiple timeline entries could be created for the same action

Consider wrapping in DB::transaction() and adding updateOrCreate() with action_id as a unique constraint.

🔒 Proposed transaction and idempotency improvement
+use Illuminate\Support\Facades\DB;
+
 public function handle(ActionExecuted $event): void
 {
     // ... existing validation ...
     
-    ModerationEvent::query()->create([
+    DB::transaction(function () use ($tenantId, $subjectIdentityId, $moderatorIdentityId, $action, $case) {
+        ModerationEvent::query()->updateOrCreate(
+            ['action_id' => $action->id],
+            [
-        'tenant_id' => $tenantId,
+                'tenant_id' => $tenantId,
         // ... rest of fields ...
-    ]);
+            ]
+        );
+    });
 }

Note: This requires adding a unique index on action_id in the migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php`
around lines 39 - 54, Wrap the ModerationEvent creation in a DB::transaction and
make it idempotent by using updateOrCreate keyed on action_id instead of create;
specifically, replace the ModerationEvent::query()->create(...) call in
PublishModerationToTimeline with DB::transaction(fn() =>
ModerationEvent::query()->updateOrCreate(['action_id' => $action->id], [
...attributes... ])) so the ModerationEvent and any observer-triggered Timeline
creation run in the same transaction, and add a DB unique index on action_id in
the migration to enforce uniqueness at the database level.

Comment on lines +11 to +14
<img
src="{{ $media->getUrl() }}"
alt=""
class="max-h-80 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add descriptive alt text for uploaded images.

Line 13 uses an empty alt, which hides non-decorative content from assistive tech.

Suggested change
-                    alt=""
+                    alt="{{ $media->name ?: 'Post image' }}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<img
src="{{ $media->getUrl() }}"
alt=""
class="max-h-80 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10"
<img
src="{{ $media->getUrl() }}"
alt="{{ $media->name ?: 'Post image' }}"
class="max-h-80 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/panel-app/resources/views/components/timeline/post-entry.blade.php`
around lines 11 - 14, The img tag using $media->getUrl() currently has an empty
alt attribute; update the img element to provide descriptive alt text (e.g. use
$media->alt, $media->getCustomProperty('alt'), $media->getDescription(), or fall
back to the post title or filename) so assistive tech can convey the image
meaning—modify the alt attribute on the <img src="{{ $media->getUrl() }}">
element to use the chosen descriptive value with a safe fallback.

Comment on lines +11 to +14
str(auth()->user()->name)
->substr(0, 2)
->upper()
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add defensive handling for short or empty user names.

The code assumes auth()->user()->name exists and has at least 2 characters. If the name is shorter or null (e.g., from incomplete profile data), substr(0, 2) may produce unexpected results or errors.

🛡️ Proposed fix to handle edge cases
 {{
-    str(auth()->user()->name)
+    str(auth()->user()->name ?? 'U')
         ->substr(0, 2)
+        ->padRight(2, '?')
         ->upper()
 }}

Or use a more robust fallback:

 {{
-    str(auth()->user()->name)
-        ->substr(0, 2)
-        ->upper()
+    str(auth()->user()->name ?? '')
+        ->whenEmpty(fn() => 'U')
+        ->limit(2, '')
+        ->upper()
+        ->whenEmpty(fn() => 'US')
 }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-app/resources/views/livewire/timeline/composer.blade.php`
around lines 11 - 14, The avatar initials generation assumes
auth()->user()->name is non-empty and at least 2 chars; modify the blade
expression that calls str(auth()->user()->name)->substr(0, 2)->upper() to
defensively handle null/short names by first coalescing to an empty string or a
fallback (e.g., auth()->user()->name ?? ''), trimming it, and then taking up to
2 characters (or using conditional logic to use the first character or a
placeholder) before calling upper(); update the expression around
str(...)->substr(0,2)->upper() to use this safe fallback so it never errors on
null or short names.

@foreach ($reply->postable->getMedia('images') as $media)
<img
src="{{ $media->getUrl() }}"
alt=""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add meaningful alt text for accessibility.

The empty alt="" attribute on reply images may hinder screen reader users. Consider:

  • Adding descriptive alt text if available in media metadata
  • Using a default like alt="Image attached to reply"
  • Or explicitly marking as decorative only if that's the intent
♿ Proposed accessibility improvement
 <img
     src="{{ $media->getUrl() }}"
-    alt=""
+    alt="Image attached to reply"
     class="max-h-60 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10"
     loading="lazy"
 />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
alt=""
<img
src="{{ $media->getUrl() }}"
alt="Image attached to reply"
class="max-h-60 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10"
loading="lazy"
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php`
at line 65, The image tag with alt="" in the thread-replies Blade template
should provide meaningful accessible text: update the img alt attribute to use
the media's metadata when available (e.g., use the reply/attachment alt/title
field) and fall back to a sensible default like "Image attached to reply"; if
the image is purely decorative, explicitly mark it as decorative by keeping
alt="" and adding aria-hidden="true" or role="presentation". Locate the img
element in thread-replies.blade.php and replace the static alt="" with a
conditional that uses the attachment's descriptive field (e.g., $attachment->alt
or $reply->media->alt) or the default string, or mark decorative images
explicitly.

Comment thread app-modules/panel-app/src/Livewire/Timeline/Feed.php
Comment on lines +25 to +47
public function mount(string $record): void
{
$exists = Timeline::query()
->where('id', $record)
->where('tenant_id', filament()->getTenant()->getKey())
->whereNull('parent_id')
->exists();

abort_unless($exists, 404);

$this->record = $record;
}

public function getTimeline(): Timeline
{
return Timeline::query()
->where('id', $this->record)
->where('tenant_id', filament()->getTenant()->getKey())
->whereNull('parent_id')
->with(['user', 'postable', 'reactions'])
->withCount('children', 'reactions')
->firstOrFail();
}

This comment was marked as resolved.

- Use addMediaFromDisk() instead of Storage::path() for S3 compatibility
- Add tenantId to CreateReplyDTO, scope parent lookup by tenant
- Wrap DeleteReply in DB::transaction
- Set type="button" on pin button to prevent form submission
- Remove children eager-load from Feed (PostShow handles its own)
- Remove unused getTimeline() from ThreadPage
Copy link
Copy Markdown

@davicbtoliveira davicbtoliveira left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Copy Markdown
Member

@gvieira18 gvieira18 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are just two minor changes, is not a problem at all.

Have we added the module template to this project? I didn't see any PHPStan files inside it.

Comment thread app-modules/activity/src/Timeline/Actions/CreatePost.php Outdated
Comment thread app-modules/activity/src/Timeline/Actions/CreateReply.php Outdated
@YuriSouzaDev
Copy link
Copy Markdown
Contributor

LGTM

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app-modules/panel-app/src/Pages/ThreadPage.php`:
- Line 13: The public Livewire property $record on the ThreadPage component can
be tampered with client-side; add the Locked attribute to it to prevent
post-mount changes. Modify the ThreadPage class by importing
Livewire\Attributes\Locked (add a use statement) and annotate the property as
#[Locked] public string $record; so Livewire treats it as a locked property and
prevents client-side mutation after mount.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: a07fba92-fa9d-4e8b-a704-82d4987a7a47

📥 Commits

Reviewing files that changed from the base of the PR and between 9a03c55 and d0f6c83.

📒 Files selected for processing (9)
  • app-modules/activity/src/Timeline/Actions/CreatePost.php
  • app-modules/activity/src/Timeline/Actions/CreateReply.php
  • app-modules/activity/src/Timeline/Actions/DeleteReply.php
  • app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
  • app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php
  • app-modules/panel-app/resources/views/components/timeline/engagement.blade.php
  • app-modules/panel-app/src/Livewire/Timeline/Feed.php
  • app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
  • app-modules/panel-app/src/Pages/ThreadPage.php
🚧 Files skipped from review as they are similar to previous changes (7)
  • app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
  • app-modules/activity/src/Timeline/Actions/DeleteReply.php
  • app-modules/activity/src/Timeline/Actions/CreatePost.php
  • app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
  • app-modules/activity/src/Timeline/Actions/CreateReply.php
  • app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php
  • app-modules/panel-app/resources/views/components/timeline/engagement.blade.php

Comment thread app-modules/panel-app/src/Pages/ThreadPage.php
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants