From c49427ea47347317f7d8cbfab4a4bf6ee05e519c Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:22:14 -0300 Subject: [PATCH 1/7] refactor(activity): restructure module into Message/Voice subdomains - Move Message model, actions, DTOs, controllers, requests to Message/ subdomain - Move Voice model, actions, DTOs, requests to Voice/ subdomain - Update namespaces in ActivityServiceProvider - Update routes and MessageReceivedEvent imports - Update related tests --- .../activity/database/factories/MessageFactory.php | 2 +- app-modules/activity/routes/message-routes.php | 2 +- .../src/{ => Message}/Actions/NewMessage.php | 4 ++-- .../src/{ => Message}/Actions/PersistMessage.php | 6 +++--- .../src/{ => Message}/DTOs/NewMessageDTO.php | 2 +- .../Admin/Resources/Messages/MessageResource.php | 14 +++++++------- .../Resources/Messages/Pages/CreateMessage.php | 4 ++-- .../Admin/Resources/Messages/Pages/EditMessage.php | 4 ++-- .../Resources/Messages/Pages/ListMessages.php | 4 ++-- .../Resources/Messages/Schemas/MessageForm.php | 2 +- .../Resources/Messages/Tables/MessagesTable.php | 2 +- .../Http/Controllers/MessagesController.php | 10 +++++----- .../Http/Requests/CreateMessageRequest.php | 4 ++-- .../activity/src/{ => Message}/Models/Message.php | 2 +- .../src/Providers/ActivityServiceProvider.php | 6 +++++- .../src/{ => Voice}/Actions/NewVoiceMessage.php | 6 +++--- .../src/{ => Voice}/DTOs/NewVoiceMessageDTO.php | 2 +- .../Http/Requests/CreateVoiceMessageRequest.php | 4 ++-- .../activity/src/{ => Voice}/Models/Voice.php | 2 +- .../Filament/Admin/Message/CreateMessageTest.php | 4 ++-- .../Filament/Admin/Message/DeleteMessageTest.php | 4 ++-- .../Filament/Admin/Message/EditMessageTest.php | 4 ++-- .../Admin/Message/ListMessageTest.phpTest.php | 4 ++-- .../Filament/Admin/Message/MessageResourceTest.php | 2 +- .../activity/tests/Unit/Actions/NewMessageTest.php | 6 +++--- .../src/Events/MessageReceivedEvent.php | 4 ++-- 26 files changed, 57 insertions(+), 53 deletions(-) rename app-modules/activity/src/{ => Message}/Actions/NewMessage.php (95%) rename app-modules/activity/src/{ => Message}/Actions/PersistMessage.php (82%) rename app-modules/activity/src/{ => Message}/DTOs/NewMessageDTO.php (96%) rename app-modules/activity/src/{ => Message}/Filament/Admin/Resources/Messages/MessageResource.php (66%) rename app-modules/activity/src/{ => Message}/Filament/Admin/Resources/Messages/Pages/CreateMessage.php (55%) rename app-modules/activity/src/{ => Message}/Filament/Admin/Resources/Messages/Pages/EditMessage.php (69%) rename app-modules/activity/src/{ => Message}/Filament/Admin/Resources/Messages/Pages/ListMessages.php (69%) rename app-modules/activity/src/{ => Message}/Filament/Admin/Resources/Messages/Schemas/MessageForm.php (96%) rename app-modules/activity/src/{ => Message}/Filament/Admin/Resources/Messages/Tables/MessagesTable.php (95%) rename app-modules/activity/src/{ => Message}/Http/Controllers/MessagesController.php (70%) rename app-modules/activity/src/{ => Message}/Http/Requests/CreateMessageRequest.php (85%) rename app-modules/activity/src/{ => Message}/Models/Message.php (96%) rename app-modules/activity/src/{ => Voice}/Actions/NewVoiceMessage.php (91%) rename app-modules/activity/src/{ => Voice}/DTOs/NewVoiceMessageDTO.php (95%) rename app-modules/activity/src/{ => Voice}/Http/Requests/CreateVoiceMessageRequest.php (83%) rename app-modules/activity/src/{ => Voice}/Models/Voice.php (93%) diff --git a/app-modules/activity/database/factories/MessageFactory.php b/app-modules/activity/database/factories/MessageFactory.php index fb2b8bc3..1c997905 100644 --- a/app-modules/activity/database/factories/MessageFactory.php +++ b/app-modules/activity/database/factories/MessageFactory.php @@ -4,7 +4,7 @@ namespace He4rt\Activity\Database\Factories; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; diff --git a/app-modules/activity/routes/message-routes.php b/app-modules/activity/routes/message-routes.php index 4f6ae559..5302bde1 100644 --- a/app-modules/activity/routes/message-routes.php +++ b/app-modules/activity/routes/message-routes.php @@ -4,7 +4,7 @@ use App\Http\Middleware\BotAuthentication; use App\Http\Middleware\VerifyIfHasTenantProviderMiddleware; -use He4rt\Activity\Http\Controllers\MessagesController; +use He4rt\Activity\Message\Http\Controllers\MessagesController; use Illuminate\Support\Facades\Route; Route::prefix('api')->middleware(['api', BotAuthentication::class, VerifyIfHasTenantProviderMiddleware::class])->group(function (): void { diff --git a/app-modules/activity/src/Actions/NewMessage.php b/app-modules/activity/src/Message/Actions/NewMessage.php similarity index 95% rename from app-modules/activity/src/Actions/NewMessage.php rename to app-modules/activity/src/Message/Actions/NewMessage.php index 8eef6224..af47078f 100644 --- a/app-modules/activity/src/Actions/NewMessage.php +++ b/app-modules/activity/src/Message/Actions/NewMessage.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace He4rt\Activity\Actions; +namespace He4rt\Activity\Message\Actions; -use He4rt\Activity\DTOs\NewMessageDTO; +use He4rt\Activity\Message\DTOs\NewMessageDTO; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\User\Actions\ResolveUserContext; diff --git a/app-modules/activity/src/Actions/PersistMessage.php b/app-modules/activity/src/Message/Actions/PersistMessage.php similarity index 82% rename from app-modules/activity/src/Actions/PersistMessage.php rename to app-modules/activity/src/Message/Actions/PersistMessage.php index cf69f553..a0fda5ee 100644 --- a/app-modules/activity/src/Actions/PersistMessage.php +++ b/app-modules/activity/src/Message/Actions/PersistMessage.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace He4rt\Activity\Actions; +namespace He4rt\Activity\Message\Actions; -use He4rt\Activity\DTOs\NewMessageDTO; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\DTOs\NewMessageDTO; +use He4rt\Activity\Message\Models\Message; class PersistMessage { diff --git a/app-modules/activity/src/DTOs/NewMessageDTO.php b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php similarity index 96% rename from app-modules/activity/src/DTOs/NewMessageDTO.php rename to app-modules/activity/src/Message/DTOs/NewMessageDTO.php index eb5c1436..8170150a 100644 --- a/app-modules/activity/src/DTOs/NewMessageDTO.php +++ b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\DTOs; +namespace He4rt\Activity\Message\DTOs; use DateTimeImmutable; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; diff --git a/app-modules/activity/src/Filament/Admin/Resources/Messages/MessageResource.php b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php similarity index 66% rename from app-modules/activity/src/Filament/Admin/Resources/Messages/MessageResource.php rename to app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php index 1c95155e..5bd09f1c 100644 --- a/app-modules/activity/src/Filament/Admin/Resources/Messages/MessageResource.php +++ b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace He4rt\Activity\Filament\Admin\Resources\Messages; +namespace He4rt\Activity\Message\Filament\Admin\Resources\Messages; use BackedEnum; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\CreateMessage; -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\EditMessage; -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\ListMessages; -use He4rt\Activity\Filament\Admin\Resources\Messages\Schemas\MessageForm; -use He4rt\Activity\Filament\Admin\Resources\Messages\Tables\MessagesTable; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\CreateMessage; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\EditMessage; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\ListMessages; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Schemas\MessageForm; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Tables\MessagesTable; +use He4rt\Activity\Message\Models\Message; use UnitEnum; class MessageResource extends Resource diff --git a/app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/CreateMessage.php b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/CreateMessage.php similarity index 55% rename from app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/CreateMessage.php rename to app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/CreateMessage.php index 546c2393..a4112b5a 100644 --- a/app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/CreateMessage.php +++ b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/CreateMessage.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace He4rt\Activity\Filament\Admin\Resources\Messages\Pages; +namespace He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages; use Filament\Resources\Pages\CreateRecord; -use He4rt\Activity\Filament\Admin\Resources\Messages\MessageResource; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\MessageResource; class CreateMessage extends CreateRecord { diff --git a/app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/EditMessage.php b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/EditMessage.php similarity index 69% rename from app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/EditMessage.php rename to app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/EditMessage.php index 1067aab3..9006c1ed 100644 --- a/app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/EditMessage.php +++ b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/EditMessage.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace He4rt\Activity\Filament\Admin\Resources\Messages\Pages; +namespace He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; -use He4rt\Activity\Filament\Admin\Resources\Messages\MessageResource; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\MessageResource; class EditMessage extends EditRecord { diff --git a/app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/ListMessages.php b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/ListMessages.php similarity index 69% rename from app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/ListMessages.php rename to app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/ListMessages.php index cf92a085..a243cb5b 100644 --- a/app-modules/activity/src/Filament/Admin/Resources/Messages/Pages/ListMessages.php +++ b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/ListMessages.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace He4rt\Activity\Filament\Admin\Resources\Messages\Pages; +namespace He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages; use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; -use He4rt\Activity\Filament\Admin\Resources\Messages\MessageResource; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\MessageResource; class ListMessages extends ListRecords { diff --git a/app-modules/activity/src/Filament/Admin/Resources/Messages/Schemas/MessageForm.php b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php similarity index 96% rename from app-modules/activity/src/Filament/Admin/Resources/Messages/Schemas/MessageForm.php rename to app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php index d2ba2f1a..0ba6ea44 100644 --- a/app-modules/activity/src/Filament/Admin/Resources/Messages/Schemas/MessageForm.php +++ b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Filament\Admin\Resources\Messages\Schemas; +namespace He4rt\Activity\Message\Filament\Admin\Resources\Messages\Schemas; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; diff --git a/app-modules/activity/src/Filament/Admin/Resources/Messages/Tables/MessagesTable.php b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php similarity index 95% rename from app-modules/activity/src/Filament/Admin/Resources/Messages/Tables/MessagesTable.php rename to app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php index 42e9d8ec..c693a416 100644 --- a/app-modules/activity/src/Filament/Admin/Resources/Messages/Tables/MessagesTable.php +++ b/app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Filament\Admin\Resources\Messages\Tables; +namespace He4rt\Activity\Message\Filament\Admin\Resources\Messages\Tables; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; diff --git a/app-modules/activity/src/Http/Controllers/MessagesController.php b/app-modules/activity/src/Message/Http/Controllers/MessagesController.php similarity index 70% rename from app-modules/activity/src/Http/Controllers/MessagesController.php rename to app-modules/activity/src/Message/Http/Controllers/MessagesController.php index cff4f107..b6faffcc 100644 --- a/app-modules/activity/src/Http/Controllers/MessagesController.php +++ b/app-modules/activity/src/Message/Http/Controllers/MessagesController.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace He4rt\Activity\Http\Controllers; +namespace He4rt\Activity\Message\Http\Controllers; use App\Http\Controllers\Controller; -use He4rt\Activity\Actions\NewMessage; -use He4rt\Activity\Actions\NewVoiceMessage; -use He4rt\Activity\Http\Requests\CreateMessageRequest; -use He4rt\Activity\Http\Requests\CreateVoiceMessageRequest; +use He4rt\Activity\Message\Actions\NewMessage; +use He4rt\Activity\Message\Http\Requests\CreateMessageRequest; +use He4rt\Activity\Voice\Actions\NewVoiceMessage; +use He4rt\Activity\Voice\Http\Requests\CreateVoiceMessageRequest; use Illuminate\Http\Response; final class MessagesController extends Controller diff --git a/app-modules/activity/src/Http/Requests/CreateMessageRequest.php b/app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php similarity index 85% rename from app-modules/activity/src/Http/Requests/CreateMessageRequest.php rename to app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php index 535bead7..5d4247ae 100644 --- a/app-modules/activity/src/Http/Requests/CreateMessageRequest.php +++ b/app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Http\Requests; +namespace He4rt\Activity\Message\Http\Requests; use Illuminate\Foundation\Http\FormRequest; @@ -17,7 +17,7 @@ public function rules(): array { return [ 'tenant_id' => ['required'], - 'provider' => ['required', 'in:twitch,discord'], + 'provider' => ['required', 'in:twitch,discord,devto'], 'provider_id' => ['required'], 'provider_message_id' => ['required'], 'channel_id' => ['required'], diff --git a/app-modules/activity/src/Models/Message.php b/app-modules/activity/src/Message/Models/Message.php similarity index 96% rename from app-modules/activity/src/Models/Message.php rename to app-modules/activity/src/Message/Models/Message.php index 981e37f0..a9cac0ed 100644 --- a/app-modules/activity/src/Models/Message.php +++ b/app-modules/activity/src/Message/Models/Message.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Models; +namespace He4rt\Activity\Message\Models; use He4rt\Activity\Database\Factories\MessageFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; diff --git a/app-modules/activity/src/Providers/ActivityServiceProvider.php b/app-modules/activity/src/Providers/ActivityServiceProvider.php index 435f5772..74ba5211 100644 --- a/app-modules/activity/src/Providers/ActivityServiceProvider.php +++ b/app-modules/activity/src/Providers/ActivityServiceProvider.php @@ -6,17 +6,21 @@ use App\Enums\FilamentPanel; use Filament\Panel; -use He4rt\Activity\Filament\Admin\Resources\Messages\MessageResource; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\MessageResource; +use He4rt\Activity\Tracking\Filament\Admin\Resources\Interactions\InteractionResource; use Illuminate\Support\ServiceProvider; class ActivityServiceProvider extends ServiceProvider { public function register(): void { + $this->mergeConfigFrom(__DIR__.'/../../config/activity-tracking.php', 'activity-tracking'); + Panel::configureUsing(function (Panel $panel): void { match ($panel->currentPanel()) { FilamentPanel::Admin => $panel->resources([ MessageResource::class, + InteractionResource::class, ]), default => null, }; diff --git a/app-modules/activity/src/Actions/NewVoiceMessage.php b/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php similarity index 91% rename from app-modules/activity/src/Actions/NewVoiceMessage.php rename to app-modules/activity/src/Voice/Actions/NewVoiceMessage.php index 139645d0..5622a7a2 100644 --- a/app-modules/activity/src/Actions/NewVoiceMessage.php +++ b/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace He4rt\Activity\Actions; +namespace He4rt\Activity\Voice\Actions; -use He4rt\Activity\DTOs\NewVoiceMessageDTO; -use He4rt\Activity\Models\Voice; +use He4rt\Activity\Voice\DTOs\NewVoiceMessageDTO; +use He4rt\Activity\Voice\Models\Voice; use He4rt\Gamification\Character\Actions\IncrementExperience; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Actions\FindExternalIdentity; diff --git a/app-modules/activity/src/DTOs/NewVoiceMessageDTO.php b/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php similarity index 95% rename from app-modules/activity/src/DTOs/NewVoiceMessageDTO.php rename to app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php index 9758994a..df574139 100644 --- a/app-modules/activity/src/DTOs/NewVoiceMessageDTO.php +++ b/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\DTOs; +namespace He4rt\Activity\Voice\DTOs; use He4rt\Gamification\Character\Enums\VoiceStatesEnum; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; diff --git a/app-modules/activity/src/Http/Requests/CreateVoiceMessageRequest.php b/app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php similarity index 83% rename from app-modules/activity/src/Http/Requests/CreateVoiceMessageRequest.php rename to app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php index 7f6ec60e..0d032c37 100644 --- a/app-modules/activity/src/Http/Requests/CreateVoiceMessageRequest.php +++ b/app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Http\Requests; +namespace He4rt\Activity\Voice\Http\Requests; use Illuminate\Foundation\Http\FormRequest; @@ -16,7 +16,7 @@ public function authorize(): bool public function rules(): array { return [ - 'provider' => ['required', 'in:twitch,discord'], + 'provider' => ['required', 'in:twitch,discord,devto'], 'provider_id' => ['required'], 'state' => ['required', 'in:muted,unmuted,disabled'], 'channel_name' => ['required', 'string'], diff --git a/app-modules/activity/src/Models/Voice.php b/app-modules/activity/src/Voice/Models/Voice.php similarity index 93% rename from app-modules/activity/src/Models/Voice.php rename to app-modules/activity/src/Voice/Models/Voice.php index 0092f7ff..38e4d434 100644 --- a/app-modules/activity/src/Models/Voice.php +++ b/app-modules/activity/src/Voice/Models/Voice.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Models; +namespace He4rt\Activity\Voice\Models; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use Illuminate\Database\Eloquent\Model; diff --git a/app-modules/activity/tests/Feature/Filament/Admin/Message/CreateMessageTest.php b/app-modules/activity/tests/Feature/Filament/Admin/Message/CreateMessageTest.php index 59b1461a..74155f7c 100644 --- a/app-modules/activity/tests/Feature/Filament/Admin/Message/CreateMessageTest.php +++ b/app-modules/activity/tests/Feature/Filament/Admin/Message/CreateMessageTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\CreateMessage; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\CreateMessage; +use He4rt\Activity\Message\Models\Message; use He4rt\Gamification\Season\Models\Season; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; diff --git a/app-modules/activity/tests/Feature/Filament/Admin/Message/DeleteMessageTest.php b/app-modules/activity/tests/Feature/Filament/Admin/Message/DeleteMessageTest.php index 94b04915..ee82c265 100644 --- a/app-modules/activity/tests/Feature/Filament/Admin/Message/DeleteMessageTest.php +++ b/app-modules/activity/tests/Feature/Filament/Admin/Message/DeleteMessageTest.php @@ -3,8 +3,8 @@ declare(strict_types=1); use Filament\Actions\DeleteAction; -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\EditMessage; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\EditMessage; +use He4rt\Activity\Message\Models\Message; use function Pest\Laravel\assertDatabaseMissing; use function Pest\Livewire\livewire; diff --git a/app-modules/activity/tests/Feature/Filament/Admin/Message/EditMessageTest.php b/app-modules/activity/tests/Feature/Filament/Admin/Message/EditMessageTest.php index cb935665..127d1932 100644 --- a/app-modules/activity/tests/Feature/Filament/Admin/Message/EditMessageTest.php +++ b/app-modules/activity/tests/Feature/Filament/Admin/Message/EditMessageTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\EditMessage; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\EditMessage; +use He4rt\Activity\Message\Models\Message; use He4rt\Gamification\Season\Models\Season; use function Pest\Laravel\assertDatabaseHas; diff --git a/app-modules/activity/tests/Feature/Filament/Admin/Message/ListMessageTest.phpTest.php b/app-modules/activity/tests/Feature/Filament/Admin/Message/ListMessageTest.phpTest.php index f2b86b39..f1851fac 100644 --- a/app-modules/activity/tests/Feature/Filament/Admin/Message/ListMessageTest.phpTest.php +++ b/app-modules/activity/tests/Feature/Filament/Admin/Message/ListMessageTest.phpTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use He4rt\Activity\Filament\Admin\Resources\Messages\Pages\ListMessages; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\Pages\ListMessages; +use He4rt\Activity\Message\Models\Message; use function Pest\Livewire\livewire; diff --git a/app-modules/activity/tests/Feature/Filament/Admin/Message/MessageResourceTest.php b/app-modules/activity/tests/Feature/Filament/Admin/Message/MessageResourceTest.php index 00292547..dd7b9995 100644 --- a/app-modules/activity/tests/Feature/Filament/Admin/Message/MessageResourceTest.php +++ b/app-modules/activity/tests/Feature/Filament/Admin/Message/MessageResourceTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Activity\Filament\Admin\Resources\Messages\MessageResource; +use He4rt\Activity\Message\Filament\Admin\Resources\Messages\MessageResource; it('can render the list page', function (): void { expect(Filament::getResources()) diff --git a/app-modules/activity/tests/Unit/Actions/NewMessageTest.php b/app-modules/activity/tests/Unit/Actions/NewMessageTest.php index 4517d38e..aa20e7f1 100644 --- a/app-modules/activity/tests/Unit/Actions/NewMessageTest.php +++ b/app-modules/activity/tests/Unit/Actions/NewMessageTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use He4rt\Activity\Actions\NewMessage; -use He4rt\Activity\Actions\PersistMessage; -use He4rt\Activity\DTOs\NewMessageDTO; +use He4rt\Activity\Message\Actions\NewMessage; +use He4rt\Activity\Message\Actions\PersistMessage; +use He4rt\Activity\Message\DTOs\NewMessageDTO; use He4rt\Community\Meeting\Actions\AttendMeeting; use He4rt\Identity\ExternalIdentity\Actions\FindExternalIdentity; use He4rt\Identity\ExternalIdentity\Actions\LinkExternalIdentity as NewAccountByProvider; diff --git a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php index fa0673a7..d873de71 100644 --- a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php +++ b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php @@ -7,8 +7,8 @@ use Discord\Discord; use Discord\Parts\Channel\Message; use Discord\WebSockets\Event as Events; -use He4rt\Activity\Actions\NewMessage; -use He4rt\Activity\DTOs\NewMessageDTO; +use He4rt\Activity\Message\Actions\NewMessage; +use He4rt\Activity\Message\DTOs\NewMessageDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; From 644964b0709dc6c38e6795e3716fd48c7b37d361 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:22:30 -0300 Subject: [PATCH 2/7] feat(activity): add Tracking subdomain with Interaction model - Add Interaction model with polymorphic source, value tier, and status - Add ActivityType, ActivityStatus, ValueTier enums - Add TrackActivity, ClassifyActivity, CalculateReward actions - Add ApproveInteraction, RejectInteraction actions for admin review - Add ActivitySourceContract for provider implementations - Add HasInteractions trait for Character model - Add InteractionTracked, InteractionApproved events - Add config/activity-tracking.php with classification rules - Add migration for interactions table - Add unit tests for all Tracking actions --- .../activity/config/activity-tracking.php | 34 +++++ .../database/factories/InteractionFactory.php | 69 ++++++++++ ...03_18_000000_create_interactions_table.php | 42 ++++++ .../Tracking/Actions/ApproveInteraction.php | 46 +++++++ .../src/Tracking/Actions/CalculateReward.php | 60 +++++++++ .../src/Tracking/Actions/ClassifyActivity.php | 34 +++++ .../Tracking/Actions/RejectInteraction.php | 21 +++ .../src/Tracking/Actions/TrackActivity.php | 73 +++++++++++ .../src/Tracking/Concerns/HasInteractions.php | 19 +++ .../Contracts/ActivitySourceContract.php | 15 +++ .../src/Tracking/DTOs/TrackActivityDTO.php | 24 ++++ .../src/Tracking/Enums/ActivityStatus.php | 40 ++++++ .../src/Tracking/Enums/ActivityType.php | 22 ++++ .../activity/src/Tracking/Enums/ValueTier.php | 34 +++++ .../Tracking/Events/InteractionApproved.php | 17 +++ .../Tracking/Events/InteractionTracked.php | 17 +++ .../Interactions/InteractionResource.php | 39 ++++++ .../Interactions/Pages/ListInteractions.php | 13 ++ .../Interactions/Pages/ViewInteraction.php | 45 +++++++ .../Interactions/Tables/InteractionsTable.php | 123 ++++++++++++++++++ .../src/Tracking/Models/Interaction.php | 112 ++++++++++++++++ .../Unit/Tracking/ApproveInteractionTest.php | 44 +++++++ .../Unit/Tracking/CalculateRewardTest.php | 53 ++++++++ .../Unit/Tracking/ClassifyActivityTest.php | 35 +++++ .../Unit/Tracking/RejectInteractionTest.php | 21 +++ .../tests/Unit/Tracking/TrackActivityTest.php | 97 ++++++++++++++ 26 files changed, 1149 insertions(+) create mode 100644 app-modules/activity/config/activity-tracking.php create mode 100644 app-modules/activity/database/factories/InteractionFactory.php create mode 100644 app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php create mode 100644 app-modules/activity/src/Tracking/Actions/ApproveInteraction.php create mode 100644 app-modules/activity/src/Tracking/Actions/CalculateReward.php create mode 100644 app-modules/activity/src/Tracking/Actions/ClassifyActivity.php create mode 100644 app-modules/activity/src/Tracking/Actions/RejectInteraction.php create mode 100644 app-modules/activity/src/Tracking/Actions/TrackActivity.php create mode 100644 app-modules/activity/src/Tracking/Concerns/HasInteractions.php create mode 100644 app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php create mode 100644 app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php create mode 100644 app-modules/activity/src/Tracking/Enums/ActivityStatus.php create mode 100644 app-modules/activity/src/Tracking/Enums/ActivityType.php create mode 100644 app-modules/activity/src/Tracking/Enums/ValueTier.php create mode 100644 app-modules/activity/src/Tracking/Events/InteractionApproved.php create mode 100644 app-modules/activity/src/Tracking/Events/InteractionTracked.php create mode 100644 app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/InteractionResource.php create mode 100644 app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ListInteractions.php create mode 100644 app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ViewInteraction.php create mode 100644 app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Tables/InteractionsTable.php create mode 100644 app-modules/activity/src/Tracking/Models/Interaction.php create mode 100644 app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php create mode 100644 app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php create mode 100644 app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php create mode 100644 app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php create mode 100644 app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php diff --git a/app-modules/activity/config/activity-tracking.php b/app-modules/activity/config/activity-tracking.php new file mode 100644 index 00000000..bc782171 --- /dev/null +++ b/app-modules/activity/config/activity-tracking.php @@ -0,0 +1,34 @@ + [ + 'article' => ['tier' => 'high', 'coins_min' => 100, 'coins_max' => 300], + 'pr_merged' => ['tier' => 'high', 'coins_min' => 80, 'coins_max' => 250], + 'mentoring' => ['tier' => 'high', 'coins_min' => 50, 'coins_max' => 150], + 'squad_project' => ['tier' => 'high', 'coins_min' => 200, 'coins_max' => 500], + 'referral' => ['tier' => 'medium', 'coins_min' => 20, 'coins_max' => 30], + 'peer_review' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 25], + 'call_participation' => ['tier' => 'medium', 'coins_min' => 15, 'coins_max' => 30], + 'forum_debate' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 20], + 'content_share' => ['tier' => 'low', 'coins_min' => 5, 'coins_max' => 10], + 'engagement' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3], + 'repo_star' => ['tier' => 'low', 'coins_min' => 2, 'coins_max' => 2], + 'message' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 2], + 'voice' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3], + ], + + 'auto_approve_tiers' => ['low', 'medium'], + + 'engagement_formula' => [ + 'reactions_multiplier' => 0.5, + 'reactions_cap' => 25, + 'bookmarks_multiplier' => 1.0, + 'bookmarks_cap' => 15, + 'comments_multiplier' => 2.0, + 'comments_cap' => 30, + ], + + 'xp_multiplier' => 1, +]; diff --git a/app-modules/activity/database/factories/InteractionFactory.php b/app-modules/activity/database/factories/InteractionFactory.php new file mode 100644 index 00000000..b9f80b5e --- /dev/null +++ b/app-modules/activity/database/factories/InteractionFactory.php @@ -0,0 +1,69 @@ + + */ +final class InteractionFactory extends Factory +{ + protected $model = Interaction::class; + + public function definition(): array + { + return [ + 'character_id' => Character::factory(), + 'tenant_id' => Tenant::factory(), + 'type' => ActivityType::Article, + 'provider' => IdentityProvider::DevTo, + 'value_tier' => ValueTier::High, + 'coins_min' => 100, + 'coins_max' => 300, + 'status' => ActivityStatus::Pending, + 'occurred_at' => now(), + ]; + } + + public function autoApproved(): self + { + return $this->state([ + 'status' => ActivityStatus::AutoApproved, + 'type' => ActivityType::Engagement, + 'value_tier' => ValueTier::Low, + 'coins_min' => 1, + 'coins_max' => 3, + ]); + } + + public function approved(): self + { + return $this->state([ + 'status' => ActivityStatus::Approved, + 'reviewed_at' => now(), + ]); + } + + public function withEngagement(int $reactions = 0, int $comments = 0, int $bookmarks = 0): self + { + return $this->state([ + 'metadata' => [ + 'engagement_snapshot' => [ + 'reactions' => $reactions, + 'comments' => $comments, + 'bookmarks' => $bookmarks, + ], + ], + ]); + } +} diff --git a/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php b/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php new file mode 100644 index 00000000..bf6569d9 --- /dev/null +++ b/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->foreignUuid('character_id')->constrained('characters'); + $table->foreignId('tenant_id')->constrained('tenants'); + $table->string('type'); + $table->string('provider'); + $table->string('value_tier'); + $table->integer('coins_min'); + $table->integer('coins_max'); + $table->integer('coins_awarded')->nullable(); + $table->integer('xp_awarded')->nullable(); + $table->string('status')->default('pending'); + $table->nullableUuidMorphs('source'); + $table->string('external_ref')->unique()->nullable(); + $table->jsonb('metadata')->nullable(); + $table->timestamp('occurred_at'); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->index(['character_id', 'type', 'created_at'], 'idx_interactions_character_type'); + $table->index(['status', 'value_tier'], 'idx_interactions_status_tier'); + $table->index(['tenant_id', 'occurred_at'], 'idx_interactions_tenant'); + }); + } + + public function down(): void + { + Schema::dropIfExists('interactions'); + } +}; diff --git a/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php b/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php new file mode 100644 index 00000000..24726272 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php @@ -0,0 +1,46 @@ +calculateReward->handle($interaction, $peerReviewBase); + + $character = Character::query()->findOrFail($interaction->character_id); + $wallet = $character->getOrCreateWallet(); + + resolve(Credit::class)->handle(new CreditDTO( + walletId: $wallet->id, + amount: $reward['coins_awarded'], + referenceType: Interaction::class, + referenceId: $interaction->id, + description: 'Reward: '.$interaction->type->value, + )); + + $character->increment('experience', $reward['xp_awarded']); + + $interaction->update([ + 'status' => ActivityStatus::Approved, + 'reviewed_at' => now(), + ]); + + event(new InteractionApproved($interaction->fresh())); + + return $interaction->fresh(); + } +} diff --git a/app-modules/activity/src/Tracking/Actions/CalculateReward.php b/app-modules/activity/src/Tracking/Actions/CalculateReward.php new file mode 100644 index 00000000..8feb443c --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/CalculateReward.php @@ -0,0 +1,60 @@ +metadata ?? []; + $engagementSnapshot = $metadata['engagement_snapshot'] ?? null; + + if ($engagementSnapshot !== null) { + $base = $peerReviewBase ?? (int) (($interaction->coins_min + $interaction->coins_max) / 2); + + $reactionsBonus = min( + ($engagementSnapshot['reactions'] ?? 0) * $engagementFormula['reactions_multiplier'], + $engagementFormula['reactions_cap'] + ); + + $bookmarksBonus = min( + ($engagementSnapshot['bookmarks'] ?? 0) * $engagementFormula['bookmarks_multiplier'], + $engagementFormula['bookmarks_cap'] + ); + + $commentsBonus = min( + ($engagementSnapshot['comments'] ?? 0) * $engagementFormula['comments_multiplier'], + $engagementFormula['comments_cap'] + ); + + $engagementBonus = (int) ($reactionsBonus + $bookmarksBonus + $commentsBonus); + $coinsAwarded = min($base + $engagementBonus, $interaction->coins_max); + } else { + $coinsAwarded = $peerReviewBase !== null + ? min($peerReviewBase, $interaction->coins_max) + : $interaction->coins_min; + } + + $xpAwarded = (int) ($coinsAwarded * $xpMultiplier); + + $interaction->update([ + 'coins_awarded' => $coinsAwarded, + 'xp_awarded' => $xpAwarded, + ]); + + return [ + 'coins_awarded' => $coinsAwarded, + 'xp_awarded' => $xpAwarded, + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php b/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php new file mode 100644 index 00000000..b307b0d0 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php @@ -0,0 +1,34 @@ +value); + + $tier = ValueTier::from($classification['tier']); + $autoApproveTiers = config('activity-tracking.auto_approve_tiers', []); + + $status = in_array($tier->value, $autoApproveTiers, true) + ? ActivityStatus::AutoApproved + : ActivityStatus::Pending; + + return [ + 'tier' => $tier, + 'coins_min' => $classification['coins_min'], + 'coins_max' => $classification['coins_max'], + 'status' => $status, + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Actions/RejectInteraction.php b/app-modules/activity/src/Tracking/Actions/RejectInteraction.php new file mode 100644 index 00000000..9ab6df7c --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/RejectInteraction.php @@ -0,0 +1,21 @@ +update([ + 'status' => ActivityStatus::Rejected, + 'reviewed_at' => now(), + ]); + + return $interaction->fresh(); + } +} diff --git a/app-modules/activity/src/Tracking/Actions/TrackActivity.php b/app-modules/activity/src/Tracking/Actions/TrackActivity.php new file mode 100644 index 00000000..52b39dbe --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/TrackActivity.php @@ -0,0 +1,73 @@ +externalRef !== null) { + $exists = Interaction::query() + ->where('external_ref', $dto->externalRef) + ->exists(); + + if ($exists) { + return null; + } + } + + $classification = $this->classifyActivity->handle($dto->type); + + $interaction = Interaction::query()->create([ + 'character_id' => $dto->characterId, + 'tenant_id' => $dto->tenantId, + 'type' => $dto->type, + 'provider' => $dto->provider, + 'value_tier' => $classification['tier'], + 'coins_min' => $classification['coins_min'], + 'coins_max' => $classification['coins_max'], + 'status' => $classification['status'], + 'source_type' => $dto->sourceType, + 'source_id' => $dto->sourceId, + 'external_ref' => $dto->externalRef, + 'metadata' => $dto->metadata, + 'occurred_at' => $dto->occurredAt, + ]); + + if ($classification['status'] === ActivityStatus::AutoApproved) { + $reward = $this->calculateReward->handle($interaction); + + $character = Character::query()->findOrFail($dto->characterId); + $wallet = $character->getOrCreateWallet(); + + resolve(Credit::class)->handle(new CreditDTO( + walletId: $wallet->id, + amount: $reward['coins_awarded'], + referenceType: Interaction::class, + referenceId: $interaction->id, + description: 'Reward: '.$dto->type->value, + )); + + $character->increment('experience', $reward['xp_awarded']); + } + + event(new InteractionTracked($interaction)); + + return $interaction; + } +} diff --git a/app-modules/activity/src/Tracking/Concerns/HasInteractions.php b/app-modules/activity/src/Tracking/Concerns/HasInteractions.php new file mode 100644 index 00000000..de9028ff --- /dev/null +++ b/app-modules/activity/src/Tracking/Concerns/HasInteractions.php @@ -0,0 +1,19 @@ + + */ + public function interactions(): HasMany + { + return $this->hasMany(Interaction::class); + } +} diff --git a/app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php b/app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php new file mode 100644 index 00000000..dd2e258d --- /dev/null +++ b/app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php @@ -0,0 +1,15 @@ + + */ + public function fetchActivities(): array; +} diff --git a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php new file mode 100644 index 00000000..30d0e05e --- /dev/null +++ b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php @@ -0,0 +1,24 @@ + 'Pending', + self::AutoApproved => 'Auto Approved', + self::InReview => 'In Review', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } + + public function getColor(): array + { + return match ($this) { + self::Pending => Color::Yellow, + self::AutoApproved => Color::Blue, + self::InReview => Color::Orange, + self::Approved => Color::Green, + self::Rejected => Color::Red, + }; + } +} diff --git a/app-modules/activity/src/Tracking/Enums/ActivityType.php b/app-modules/activity/src/Tracking/Enums/ActivityType.php new file mode 100644 index 00000000..0ad73155 --- /dev/null +++ b/app-modules/activity/src/Tracking/Enums/ActivityType.php @@ -0,0 +1,22 @@ + 'High', + self::Medium => 'Medium', + self::Low => 'Low', + }; + } + + public function getColor(): array + { + return match ($this) { + self::High => Color::Purple, + self::Medium => Color::Blue, + self::Low => Color::Gray, + }; + } +} diff --git a/app-modules/activity/src/Tracking/Events/InteractionApproved.php b/app-modules/activity/src/Tracking/Events/InteractionApproved.php new file mode 100644 index 00000000..0e4d11d7 --- /dev/null +++ b/app-modules/activity/src/Tracking/Events/InteractionApproved.php @@ -0,0 +1,17 @@ + ListInteractions::route('/'), + 'view' => ViewInteraction::route('/{record}'), + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ListInteractions.php b/app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ListInteractions.php new file mode 100644 index 00000000..0bfe66a9 --- /dev/null +++ b/app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ListInteractions.php @@ -0,0 +1,13 @@ +components([ + Section::make('Interaction Details') + ->schema([ + TextEntry::make('type')->badge(), + TextEntry::make('provider')->badge(), + TextEntry::make('status')->badge(), + TextEntry::make('value_tier')->badge()->label('Tier'), + TextEntry::make('character.user.name')->label('User'), + TextEntry::make('coins_min')->label('Min Coins'), + TextEntry::make('coins_max')->label('Max Coins'), + TextEntry::make('coins_awarded')->label('Awarded'), + TextEntry::make('xp_awarded')->label('XP Awarded'), + TextEntry::make('external_ref')->label('External Ref'), + TextEntry::make('occurred_at')->dateTime('d/m/Y H:i'), + TextEntry::make('reviewed_at')->dateTime('d/m/Y H:i'), + ])->columns(3), + + Section::make('Metadata') + ->schema([ + TextEntry::make('metadata') + ->formatStateUsing(fn (?array $state): string => $state ? json_encode($state, JSON_PRETTY_PRINT) : 'N/A') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Tables/InteractionsTable.php b/app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Tables/InteractionsTable.php new file mode 100644 index 00000000..c5fe5b08 --- /dev/null +++ b/app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Tables/InteractionsTable.php @@ -0,0 +1,123 @@ +columns([ + TextColumn::make('status') + ->badge() + ->sortable(), + + TextColumn::make('type') + ->label('Type') + ->sortable() + ->searchable(), + + TextColumn::make('provider') + ->badge() + ->sortable(), + + TextColumn::make('character.user.name') + ->label('User') + ->searchable(), + + TextColumn::make('value_tier') + ->badge() + ->label('Tier') + ->sortable(), + + TextColumn::make('coins_range') + ->label('Coins Range') + ->state(fn (Interaction $record): string => sprintf('%d-%d', $record->coins_min, $record->coins_max)), + + TextColumn::make('coins_awarded') + ->label('Awarded') + ->numeric() + ->sortable(), + + TextColumn::make('occurred_at') + ->label('Occurred') + ->dateTime('d/m/Y H:i') + ->sortable(), + ]) + ->defaultSort('occurred_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->options(ActivityStatus::class), + + SelectFilter::make('type') + ->options(ActivityType::class), + + SelectFilter::make('value_tier') + ->options(ValueTier::class), + ]) + ->recordActions([ + ViewAction::make(), + Action::make('approve') + ->label('Approve') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->visible(fn (Interaction $record): bool => $record->status === ActivityStatus::Pending) + ->action(fn (Interaction $record) => resolve(ApproveInteraction::class)->handle($record)), + + Action::make('reject') + ->label('Reject') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Interaction $record): bool => $record->status === ActivityStatus::Pending) + ->action(fn (Interaction $record) => resolve(RejectInteraction::class)->handle($record)), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_approve') + ->label('Approve Selected') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->action(function (Collection $records): void { + $approveAction = resolve(ApproveInteraction::class); + $records->each(fn (Interaction $record) => $record->status === ActivityStatus::Pending + ? $approveAction->handle($record) + : null + ); + }), + + BulkAction::make('bulk_reject') + ->label('Reject Selected') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->action(function (Collection $records): void { + $rejectAction = resolve(RejectInteraction::class); + $records->each(fn (Interaction $record) => $record->status === ActivityStatus::Pending + ? $rejectAction->handle($record) + : null + ); + }), + ]), + ]); + } +} diff --git a/app-modules/activity/src/Tracking/Models/Interaction.php b/app-modules/activity/src/Tracking/Models/Interaction.php new file mode 100644 index 00000000..717a56c8 --- /dev/null +++ b/app-modules/activity/src/Tracking/Models/Interaction.php @@ -0,0 +1,112 @@ + + */ + public function character(): BelongsTo + { + return $this->belongsTo(Character::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return MorphTo + */ + public function source(): MorphTo + { + return $this->morphTo(); + } + + protected static function newFactory(): InteractionFactory + { + return InteractionFactory::new(); + } + + protected function casts(): array + { + return [ + 'type' => ActivityType::class, + 'provider' => IdentityProvider::class, + 'value_tier' => ValueTier::class, + 'status' => ActivityStatus::class, + 'coins_min' => 'integer', + 'coins_max' => 'integer', + 'coins_awarded' => 'integer', + 'xp_awarded' => 'integer', + 'metadata' => 'array', + 'occurred_at' => 'datetime', + 'reviewed_at' => 'datetime', + ]; + } +} diff --git a/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php new file mode 100644 index 00000000..5ee83168 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php @@ -0,0 +1,44 @@ +create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(['experience' => 500]); + + $interaction = Interaction::factory() + ->withEngagement(reactions: 42, bookmarks: 8, comments: 12) + ->recycle($character) + ->recycle($tenant) + ->create([ + 'coins_min' => 100, + 'coins_max' => 300, + 'status' => ActivityStatus::Pending, + ]); + + $result = resolve(ApproveInteraction::class)->handle($interaction, peerReviewBase: 200); + + expect($result->status)->toBe(ActivityStatus::Approved) + ->and($result->reviewed_at)->not->toBeNull() + ->and($result->coins_awarded)->toBe(253); + + $wallet = $character->fresh()->wallets()->first(); + expect($wallet->balance)->toBe(253); + + Event::assertDispatched(InteractionApproved::class); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php b/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php new file mode 100644 index 00000000..ccc70c40 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php @@ -0,0 +1,53 @@ +withEngagement(reactions: 42, bookmarks: 8, comments: 12) + ->create([ + 'coins_min' => 100, + 'coins_max' => 300, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction, peerReviewBase: 200); + + // reactions bonus: min(42 * 0.5, 25) = 21 + // bookmarks bonus: min(8 * 1.0, 15) = 8 + // comments bonus: min(12 * 2.0, 30) = 24 + // total engagement: 53 + // coins_awarded: min(200 + 53, 300) = 253 + expect($result['coins_awarded'])->toBe(253) + ->and($result['xp_awarded'])->toBe(253); +}); + +test('caps engagement bonus at coins max', function (): void { + $interaction = Interaction::factory() + ->withEngagement(reactions: 100, bookmarks: 100, comments: 100) + ->create([ + 'coins_min' => 100, + 'coins_max' => 200, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction, peerReviewBase: 180); + + expect($result['coins_awarded'])->toBe(200); +}); + +test('uses coins_min when no engagement and auto approved', function (): void { + $interaction = Interaction::factory()->create([ + 'coins_min' => 5, + 'coins_max' => 10, + 'metadata' => null, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction); + + expect($result['coins_awarded'])->toBe(5); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php b/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php new file mode 100644 index 00000000..28c986fa --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php @@ -0,0 +1,35 @@ +handle(ActivityType::Article); + + expect($result['tier'])->toBe(ValueTier::High) + ->and($result['coins_min'])->toBe(100) + ->and($result['coins_max'])->toBe(300) + ->and($result['status'])->toBe(ActivityStatus::Pending); +}); + +test('classifies medium tier activity as auto approved', function (): void { + $result = resolve(ClassifyActivity::class)->handle(ActivityType::Referral); + + expect($result['tier'])->toBe(ValueTier::Medium) + ->and($result['coins_min'])->toBe(20) + ->and($result['coins_max'])->toBe(30) + ->and($result['status'])->toBe(ActivityStatus::AutoApproved); +}); + +test('classifies low tier activity as auto approved', function (): void { + $result = resolve(ClassifyActivity::class)->handle(ActivityType::Engagement); + + expect($result['tier'])->toBe(ValueTier::Low) + ->and($result['coins_min'])->toBe(1) + ->and($result['coins_max'])->toBe(3) + ->and($result['status'])->toBe(ActivityStatus::AutoApproved); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php new file mode 100644 index 00000000..a2ffd439 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php @@ -0,0 +1,21 @@ +create([ + 'status' => ActivityStatus::Pending, + ]); + + $result = resolve(RejectInteraction::class)->handle($interaction); + + expect($result->status)->toBe(ActivityStatus::Rejected) + ->and($result->reviewed_at)->not->toBeNull(); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php new file mode 100644 index 00000000..181f8f6b --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php @@ -0,0 +1,97 @@ +create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + tenantId: $tenant->id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + externalRef: 'devto:article:123', + ); + + $interaction = resolve(TrackActivity::class)->handle($dto); + + expect($interaction)->not->toBeNull() + ->and($interaction->status)->toBe(ActivityStatus::Pending) + ->and($interaction->value_tier)->toBe(ValueTier::High) + ->and($interaction->coins_min)->toBe(100) + ->and($interaction->coins_max)->toBe(300) + ->and($interaction->coins_awarded)->toBeNull(); + + Event::assertDispatched(InteractionTracked::class); +}); + +test('tracks low tier activity as auto approved and credits economy', function (): void { + Event::fake([InteractionTracked::class]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + tenantId: $tenant->id, + type: ActivityType::Engagement, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + ); + + $interaction = resolve(TrackActivity::class)->handle($dto); + + expect($interaction)->not->toBeNull() + ->and($interaction->status)->toBe(ActivityStatus::AutoApproved) + ->and($interaction->value_tier)->toBe(ValueTier::Low) + ->and($interaction->coins_awarded)->toBe(1); + + $wallet = $character->fresh()->wallets()->first(); + expect($wallet)->not->toBeNull() + ->and($wallet->balance)->toBe(1); + + Event::assertDispatched(InteractionTracked::class); +}); + +test('deduplicates by external ref', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + tenantId: $tenant->id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + externalRef: 'devto:article:456', + ); + + $first = resolve(TrackActivity::class)->handle($dto); + $second = resolve(TrackActivity::class)->handle($dto); + + expect($first)->not->toBeNull() + ->and($second)->toBeNull(); +}); From 32e815b103d8dca96cf4f3fa61f8e9045e2ec2c3 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:22:55 -0300 Subject: [PATCH 3/7] feat(integration-devto): add new module for DevTo OAuth and article sync - Add DevToOAuthClient implementing OAuthClientContract - Add DevToOAuthAccessDTO and DevToOAuthUser - Add DevToApiClient for DevTo API wrapper - Add SyncDevToArticles artisan command for scheduled polling - Add IntegrationDevToServiceProvider - Add config/integration-devto.php with org_slug and polling settings - Add feature tests for OAuth and sync --- app-modules/integration-devto/composer.json | 24 +++ .../config/integration-devto.php | 9 ++ .../src/OAuth/DevToOAuthAccessDTO.php | 19 +++ .../src/OAuth/DevToOAuthClient.php | 46 ++++++ .../src/OAuth/DevToOAuthUser.php | 25 +++ .../src/Polling/DevToApiClient.php | 32 ++++ .../src/Polling/SyncDevToArticles.php | 143 ++++++++++++++++++ .../IntegrationDevToServiceProvider.php | 31 ++++ .../tests/Feature/DevToOAuthTest.php | 69 +++++++++ .../tests/Feature/SyncDevToArticlesTest.php | 127 ++++++++++++++++ composer.json | 1 + composer.lock | 36 +++++ 12 files changed, 562 insertions(+) create mode 100644 app-modules/integration-devto/composer.json create mode 100644 app-modules/integration-devto/config/integration-devto.php create mode 100644 app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php create mode 100644 app-modules/integration-devto/src/OAuth/DevToOAuthClient.php create mode 100644 app-modules/integration-devto/src/OAuth/DevToOAuthUser.php create mode 100644 app-modules/integration-devto/src/Polling/DevToApiClient.php create mode 100644 app-modules/integration-devto/src/Polling/SyncDevToArticles.php create mode 100644 app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php create mode 100644 app-modules/integration-devto/tests/Feature/DevToOAuthTest.php create mode 100644 app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php diff --git a/app-modules/integration-devto/composer.json b/app-modules/integration-devto/composer.json new file mode 100644 index 00000000..cabc606f --- /dev/null +++ b/app-modules/integration-devto/composer.json @@ -0,0 +1,24 @@ +{ + "name": "he4rt/integration-devto", + "description": "", + "type": "library", + "version": "1.0", + "license": "proprietary", + "require": {}, + "autoload": { + "psr-4": { + "He4rt\\IntegrationDevTo\\": "src/", + "He4rt\\IntegrationDevTo\\Tests\\": "tests/", + "He4rt\\IntegrationDevTo\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationDevTo\\Database\\Seeders\\": "database/seeders/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationDevTo\\Providers\\IntegrationDevToServiceProvider" + ] + } + } +} diff --git a/app-modules/integration-devto/config/integration-devto.php b/app-modules/integration-devto/config/integration-devto.php new file mode 100644 index 00000000..b694d339 --- /dev/null +++ b/app-modules/integration-devto/config/integration-devto.php @@ -0,0 +1,9 @@ + env('DEVTO_ORG_SLUG', 'he4rt'), + 'api_base_url' => env('DEVTO_API_URL', 'https://dev.to/api'), + 'polling_interval_minutes' => env('DEVTO_POLLING_INTERVAL', 30), +]; diff --git a/app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php b/app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php new file mode 100644 index 00000000..96a0e485 --- /dev/null +++ b/app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php @@ -0,0 +1,19 @@ + config('services.devto.client_id'), + 'response_type' => 'code', + 'redirect_uri' => config('services.devto.redirect_uri'), + 'scope' => config('services.devto.scopes'), + 'state' => (string) $state, + ]); + } + + public function auth(string $code): OAuthAccessDTO + { + $response = Http::asForm()->post('https://dev.to/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => config('services.devto.redirect_uri'), + 'client_id' => config('services.devto.client_id'), + 'client_secret' => config('services.devto.client_secret'), + ]); + + return DevToOAuthAccessDTO::make($response->json()); + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO + { + $response = Http::withToken($credentials->accessToken) + ->get('https://dev.to/api/users/me'); + + return DevToOAuthUser::make($credentials, $response->json()); + } +} diff --git a/app-modules/integration-devto/src/OAuth/DevToOAuthUser.php b/app-modules/integration-devto/src/OAuth/DevToOAuthUser.php new file mode 100644 index 00000000..7802a711 --- /dev/null +++ b/app-modules/integration-devto/src/OAuth/DevToOAuthUser.php @@ -0,0 +1,25 @@ + $orgSlug, + 'per_page' => $perPage, + 'page' => $page, + ]); + + return $response->json() ?? []; + } + + public function getArticle(int $articleId): array + { + $baseUrl = config('integration-devto.api_base_url'); + + $response = Http::get("{$baseUrl}/articles/{$articleId}"); + + return $response->json() ?? []; + } +} diff --git a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php new file mode 100644 index 00000000..3db4aa56 --- /dev/null +++ b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php @@ -0,0 +1,143 @@ +info("Syncing articles from DevTo org: {$orgSlug}"); + + do { + $articles = $this->apiClient->getArticlesByOrg($orgSlug, $page); + + foreach ($articles as $article) { + $result = $this->processArticle($article); + + match ($result) { + 'created' => $totalCreated++, + 'updated' => $totalUpdated++, + 'skipped' => $totalSkipped++, + }; + } + + $page++; + } while (count($articles) === 30); + + $this->info("Sync complete: {$totalCreated} created, {$totalUpdated} updated, {$totalSkipped} skipped"); + + return self::SUCCESS; + } + + private function processArticle(array $article): string + { + $devToUsername = $article['user']['username'] ?? null; + + if ($devToUsername === null) { + return 'skipped'; + } + + $externalIdentity = ExternalIdentity::query() + ->where('provider', IdentityProvider::DevTo) + ->where('username', $devToUsername) + ->where('model_type', User::class) + ->first(); + + if ($externalIdentity === null) { + Log::info('DevTo sync: author not linked', [ + 'devto_username' => $devToUsername, + 'article_id' => $article['id'], + 'title' => $article['title'], + ]); + + return 'skipped'; + } + + $externalRef = "devto:article:{$article['id']}"; + + $existingInteraction = Interaction::query() + ->where('external_ref', $externalRef) + ->first(); + + if ($existingInteraction !== null) { + $articleDetails = $this->apiClient->getArticle($article['id']); + + $existingInteraction->update([ + 'metadata' => array_merge($existingInteraction->metadata ?? [], [ + 'engagement_snapshot' => [ + 'reactions' => $articleDetails['public_reactions_count'] ?? $article['public_reactions_count'] ?? 0, + 'comments' => $articleDetails['comments_count'] ?? $article['comments_count'] ?? 0, + 'bookmarks' => $articleDetails['reading_list_count'] ?? 0, + ], + ]), + ]); + + return 'updated'; + } + + $character = $externalIdentity->user?->character; + + if ($character === null) { + Log::info('DevTo sync: user has no character', [ + 'devto_username' => $devToUsername, + 'user_id' => $externalIdentity->model_id, + ]); + + return 'skipped'; + } + + $articleDetails = $this->apiClient->getArticle($article['id']); + + $this->trackActivity->handle(new TrackActivityDTO( + characterId: $character->id, + tenantId: (int) $externalIdentity->tenant_id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: new DateTimeImmutable($article['published_at'] ?? $article['created_at']), + externalRef: $externalRef, + metadata: [ + 'devto_article_id' => $article['id'], + 'title' => $article['title'], + 'url' => $article['url'], + 'tags' => $article['tag_list'] ?? [], + 'engagement_snapshot' => [ + 'reactions' => $articleDetails['public_reactions_count'] ?? $article['public_reactions_count'] ?? 0, + 'comments' => $articleDetails['comments_count'] ?? $article['comments_count'] ?? 0, + 'bookmarks' => $articleDetails['reading_list_count'] ?? 0, + ], + ], + )); + + return 'created'; + } +} diff --git a/app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php b/app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php new file mode 100644 index 00000000..5629c9f5 --- /dev/null +++ b/app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php @@ -0,0 +1,31 @@ +mergeConfigFrom(__DIR__.'/../../config/integration-devto.php', 'integration-devto'); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + SyncDevToArticles::class, + ]); + + $this->app->booted(function (): void { + $schedule = $this->app->make(Schedule::class); + $schedule->command('devto:sync-articles')->everyThirtyMinutes(); + }); + } + } +} diff --git a/app-modules/integration-devto/tests/Feature/DevToOAuthTest.php b/app-modules/integration-devto/tests/Feature/DevToOAuthTest.php new file mode 100644 index 00000000..1098ecd2 --- /dev/null +++ b/app-modules/integration-devto/tests/Feature/DevToOAuthTest.php @@ -0,0 +1,69 @@ + 'test-client-id', + 'services.devto.redirect_uri' => 'https://example.com/callback', + 'services.devto.scopes' => 'public', + ]); + + $client = new DevToOAuthClient(); + $url = $client->redirectUrl(); + + expect($url)->toContain('https://dev.to/oauth/authorize') + ->and($url)->toContain('client_id=test-client-id') + ->and($url)->toContain('response_type=code') + ->and($url)->toContain('scope=public'); +}); + +test('exchanges code for access token', function (): void { + Http::fake([ + 'dev.to/oauth/token' => Http::response([ + 'access_token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 3600, + ]), + ]); + + $client = new DevToOAuthClient(); + $dto = $client->auth('test-code'); + + expect($dto)->toBeInstanceOf(DevToOAuthAccessDTO::class) + ->and($dto->accessToken)->toBe('test-access-token') + ->and($dto->refreshToken)->toBe('test-refresh-token'); +}); + +test('fetches authenticated user info', function (): void { + Http::fake([ + 'dev.to/api/users/me' => Http::response([ + 'id' => 12345, + 'username' => 'testuser', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'profile_image' => 'https://dev.to/avatar.png', + ]), + ]); + + $accessDTO = DevToOAuthAccessDTO::make([ + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_in' => 3600, + ]); + + $client = new DevToOAuthClient(); + $user = $client->getAuthenticatedUser($accessDTO); + + expect($user)->toBeInstanceOf(DevToOAuthUser::class) + ->and($user->providerId)->toBe('12345') + ->and($user->provider)->toBe(IdentityProvider::DevTo) + ->and($user->username)->toBe('testuser') + ->and($user->name)->toBe('Test User'); +}); diff --git a/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php b/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php new file mode 100644 index 00000000..175c46f1 --- /dev/null +++ b/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php @@ -0,0 +1,127 @@ + Http::response([ + [ + 'id' => 101, + 'title' => 'PHP is awesome', + 'url' => 'https://dev.to/linked_user/php-is-awesome', + 'published_at' => '2026-03-15T10:00:00Z', + 'created_at' => '2026-03-15T09:00:00Z', + 'tag_list' => ['php', 'laravel'], + 'public_reactions_count' => 10, + 'comments_count' => 3, + 'user' => ['username' => 'linked_user'], + ], + [ + 'id' => 102, + 'title' => 'Unlinked article', + 'url' => 'https://dev.to/unknown_user/unlinked', + 'published_at' => '2026-03-16T10:00:00Z', + 'created_at' => '2026-03-16T09:00:00Z', + 'tag_list' => ['go'], + 'public_reactions_count' => 5, + 'comments_count' => 1, + 'user' => ['username' => 'unknown_user'], + ], + ]), + '*/articles?*page=2*' => Http::response([]), + '*/articles/101' => Http::response([ + 'id' => 101, + 'public_reactions_count' => 10, + 'comments_count' => 3, + 'reading_list_count' => 2, + ]), + ]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'provider' => IdentityProvider::DevTo, + 'username' => 'linked_user', + ]); + + $this->artisan('devto:sync-articles') + ->assertSuccessful(); + + expect(Interaction::query()->count())->toBe(1); + + $interaction = Interaction::query()->first(); + expect($interaction->type)->toBe(ActivityType::Article) + ->and($interaction->external_ref)->toBe('devto:article:101') + ->and($interaction->metadata['title'])->toBe('PHP is awesome') + ->and($interaction->metadata['engagement_snapshot']['reactions'])->toBe(10); +}); + +test('updates engagement for existing interactions without creating duplicates', function (): void { + Http::fake([ + '*/articles?*page=1*' => Http::response([ + [ + 'id' => 201, + 'title' => 'Existing article', + 'url' => 'https://dev.to/author/existing', + 'published_at' => '2026-03-10T10:00:00Z', + 'created_at' => '2026-03-10T09:00:00Z', + 'tag_list' => ['php'], + 'public_reactions_count' => 50, + 'comments_count' => 10, + 'user' => ['username' => 'author'], + ], + ]), + '*/articles?*page=2*' => Http::response([]), + '*/articles/201' => Http::response([ + 'id' => 201, + 'public_reactions_count' => 50, + 'comments_count' => 10, + 'reading_list_count' => 5, + ]), + ]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'provider' => IdentityProvider::DevTo, + 'username' => 'author', + ]); + + Interaction::factory()->recycle($character)->recycle($tenant)->create([ + 'external_ref' => 'devto:article:201', + 'metadata' => [ + 'engagement_snapshot' => ['reactions' => 20, 'comments' => 5, 'bookmarks' => 1], + ], + ]); + + $this->artisan('devto:sync-articles') + ->assertSuccessful(); + + expect(Interaction::query()->count())->toBe(1); + + $interaction = Interaction::query()->first(); + expect($interaction->metadata['engagement_snapshot']['reactions'])->toBe(50) + ->and($interaction->metadata['engagement_snapshot']['bookmarks'])->toBe(5); +}); diff --git a/composer.json b/composer.json index 3bbe0ae8..dfc3e8d9 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "he4rt/gamification": ">=1", "he4rt/he4rt-core": ">=1", "he4rt/identity": ">=1", + "he4rt/integration-devto": ">=1", "he4rt/integration-discord": ">=1", "he4rt/integration-twitch": ">=1", "he4rt/portal": ">=1", diff --git a/composer.lock b/composer.lock index d9217728..22a56a05 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,11 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], +<<<<<<< Updated upstream "content-hash": "936b4cc314a0d419109f258378ab4155", +======= + "content-hash": "1ac02c549cf1602156217656ada9bdd6", +>>>>>>> Stashed changes "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -3350,6 +3354,38 @@ "relative": true } }, + { + "name": "he4rt/integration-devto", + "version": "1.0", + "dist": { + "type": "path", + "url": "app-modules/integration-devto", + "reference": "a4c27199e7e14ef5a965447d3cd3427cb8fbbe86" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationDevTo\\Providers\\IntegrationDevToServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "He4rt\\IntegrationDevTo\\": "src/", + "He4rt\\IntegrationDevTo\\Tests\\": "tests/", + "He4rt\\IntegrationDevTo\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationDevTo\\Database\\Seeders\\": "database/seeders/" + } + }, + "license": [ + "proprietary" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "he4rt/integration-discord", "version": "1.0", From e1215c1fda3f1566ae62c85cd510e726074ad9be Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:23:04 -0300 Subject: [PATCH 4/7] feat(identity): add DevTo to IdentityProvider enum - Add DevTo case to IdentityProvider enum with OAuth client binding - Add getClient(), getColor(), getIcon(), getDescription(), getScopes(), isEnabled() methods - Add devto config block to services.php - Add ExternalIdentity DevTo relationship support - Update tests for FindProfile and UpdateProfile --- .../src/ExternalIdentity/Enums/IdentityProvider.php | 7 +++++++ .../src/ExternalIdentity/Models/ExternalIdentity.php | 2 +- app-modules/identity/src/Tenant/Models/Tenant.php | 2 +- app-modules/identity/tests/Feature/FindProfileTest.php | 2 +- app-modules/identity/tests/Feature/UpdateProfileTest.php | 2 +- config/services.php | 8 ++++++++ database/seeders/BaseSeeder.php | 2 +- 7 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php index 4ab353a5..6c43e800 100644 --- a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php +++ b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php @@ -11,6 +11,7 @@ use Filament\Support\Contracts\HasIcon; use Filament\Support\Contracts\HasLabel; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; +use He4rt\IntegrationDevTo\OAuth\DevToOAuthClient; use He4rt\IntegrationDiscord\OAuth\DiscordOAuthClient; use He4rt\IntegrationTwitch\OAuth\Contracts\TwitchOAuthService; use Illuminate\Contracts\Support\Htmlable; @@ -19,12 +20,14 @@ enum IdentityProvider: string implements HasColor, HasDescription, HasIcon, HasL { case Discord = 'discord'; case Twitch = 'twitch'; + case DevTo = 'devto'; public function getClient(): OAuthClientContract { return match ($this) { self::Twitch => resolve(TwitchOAuthService::class), self::Discord => resolve(DiscordOAuthClient::class), + self::DevTo => resolve(DevToOAuthClient::class), }; } @@ -33,6 +36,7 @@ public function getColor(): array return match ($this) { self::Discord => Color::Blue, self::Twitch => Color::Purple, + self::DevTo => Color::Gray, }; } @@ -41,6 +45,7 @@ public function getIcon(): string return match ($this) { self::Discord => 'fab-discord', self::Twitch => 'fab-twitch', + self::DevTo => 'fab-dev', }; } @@ -54,6 +59,7 @@ public function getDescription(): string|Htmlable|null return match ($this) { self::Discord => 'Conecte sua conta do Discord para gameficações e eventos.', self::Twitch => 'Conecte sua conta do Twitch para gameficações e eventos.', + self::DevTo => 'Conecte sua conta do Dev.to para rastrear artigos e contribuições.', }; } @@ -62,6 +68,7 @@ public function getScopes(): array $scopes = match ($this) { self::Discord => config('services.discord.scopes'), self::Twitch => config('services.twitch.scopes'), + self::DevTo => config('services.devto.scopes'), }; return explode(' ', $scopes); diff --git a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php index 032c4835..4c4861bf 100644 --- a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php @@ -4,7 +4,7 @@ namespace He4rt\Identity\ExternalIdentity\Models; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Identity\Database\Factories\ExternalIdentityFactory; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\User\Models\User; diff --git a/app-modules/identity/src/Tenant/Models/Tenant.php b/app-modules/identity/src/Tenant/Models/Tenant.php index bec3a316..d73d2a4f 100644 --- a/app-modules/identity/src/Tenant/Models/Tenant.php +++ b/app-modules/identity/src/Tenant/Models/Tenant.php @@ -4,7 +4,7 @@ namespace He4rt\Identity\Tenant\Models; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Events\Models\EventModel; use He4rt\Gamification\Character\Models\PastSeason; use He4rt\Gamification\Season\Models\Season; diff --git a/app-modules/identity/tests/Feature/FindProfileTest.php b/app-modules/identity/tests/Feature/FindProfileTest.php index 022ac309..68e0423a 100644 --- a/app-modules/identity/tests/Feature/FindProfileTest.php +++ b/app-modules/identity/tests/Feature/FindProfileTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Character\Models\Character; use He4rt\Gamification\Character\Models\PastSeason; diff --git a/app-modules/identity/tests/Feature/UpdateProfileTest.php b/app-modules/identity/tests/Feature/UpdateProfileTest.php index 4404b65a..59d8fee4 100644 --- a/app-modules/identity/tests/Feature/UpdateProfileTest.php +++ b/app-modules/identity/tests/Feature/UpdateProfileTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Gamification\Character\Models\Character; use He4rt\Gamification\Character\Models\PastSeason; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; diff --git a/config/services.php b/config/services.php index 24f30803..7b650816 100644 --- a/config/services.php +++ b/config/services.php @@ -49,4 +49,12 @@ 'enabled' => env('TWITCH_OAUTH_ENABLED', true), ], + 'devto' => [ + 'client_id' => env('DEVTO_OAUTH_CLIENT_ID'), + 'client_secret' => env('DEVTO_OAUTH_CLIENT_SECRET'), + 'redirect_uri' => env('DEVTO_OAUTH_REDIRECT_URI', 'https://localhost:8000/auth/oauth/devto'), + 'scopes' => env('DEVTO_OAUTH_SCOPES', 'public'), + 'enabled' => env('DEVTO_OAUTH_ENABLED', false), + ], + ]; diff --git a/database/seeders/BaseSeeder.php b/database/seeders/BaseSeeder.php index 2785a883..81025234 100644 --- a/database/seeders/BaseSeeder.php +++ b/database/seeders/BaseSeeder.php @@ -4,7 +4,7 @@ namespace Database\Seeders; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Community\Meeting\Models\Meeting; use He4rt\Events\Models\EventModel; use He4rt\Gamification\Character\Models\Character; From 89f2385237e66d0aeb9637c5bc2cf2fce375c33a Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:23:15 -0300 Subject: [PATCH 5/7] feat(gamification): add HasInteractions trait to Character model - Add HasInteractions trait for interactions relationship - Use trait in Character model for activity tracking integration --- app-modules/gamification/src/Character/Models/Character.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app-modules/gamification/src/Character/Models/Character.php b/app-modules/gamification/src/Character/Models/Character.php index 38f40931..9af73a90 100644 --- a/app-modules/gamification/src/Character/Models/Character.php +++ b/app-modules/gamification/src/Character/Models/Character.php @@ -5,7 +5,9 @@ namespace He4rt\Gamification\Character\Models; use Carbon\Carbon; +use He4rt\Activity\Tracking\Concerns\HasInteractions; use He4rt\Economy\Concerns\HasWallet; +use He4rt\Economy\Models\Wallet; use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Database\Factories\CharacterFactory; use He4rt\Identity\Tenant\Models\Tenant; @@ -30,6 +32,7 @@ final class Character extends Model { use HasFactory; + use HasInteractions; use HasUuids; use HasWallet; From 1029afd5de3d8d49a0192d3479d5c70bd6f7951a Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:39:11 -0300 Subject: [PATCH 6/7] deps: bump and fix lock --- composer.json | 4 ++-- composer.lock | 48 ++++++++++++++++++++++-------------------------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index dfc3e8d9..09d9bfb9 100644 --- a/composer.json +++ b/composer.json @@ -30,8 +30,8 @@ "he4rt/portal": ">=1", "internachi/modular": "dev-main#ad95fe9", "laracord/framework": "dev-next", - "laravel/framework": "^12.55.0", - "laravel/nightwatch": "^1.24.3", + "laravel/framework": "^12.55.1", + "laravel/nightwatch": "^1.24.4", "laravel/sanctum": "^4.3.1", "laravel/telescope": "^5.18.0", "laravel/tinker": "^2.11.1", diff --git a/composer.lock b/composer.lock index 22a56a05..bb53921e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,11 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], -<<<<<<< Updated upstream - "content-hash": "936b4cc314a0d419109f258378ab4155", -======= - "content-hash": "1ac02c549cf1602156217656ada9bdd6", ->>>>>>> Stashed changes + "content-hash": "dd4e4c2d1d3eed80e38083e2b29bd83d", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -3848,16 +3844,16 @@ }, { "name": "laravel/framework", - "version": "v12.55.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6917962a55ac91598e8c5e2ebd25e27aed121b5e" + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6917962a55ac91598e8c5e2ebd25e27aed121b5e", - "reference": "6917962a55ac91598e8c5e2ebd25e27aed121b5e", + "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", "shasum": "" }, "require": { @@ -4066,20 +4062,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-17T14:19:00+00:00" + "time": "2026-03-18T14:28:59+00:00" }, { "name": "laravel/nightwatch", - "version": "v1.24.3", + "version": "v1.24.4", "source": { "type": "git", "url": "https://github.com/laravel/nightwatch.git", - "reference": "627e58096f6b4b88e658b7a9cff595089036ae84" + "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/nightwatch/zipball/627e58096f6b4b88e658b7a9cff595089036ae84", - "reference": "627e58096f6b4b88e658b7a9cff595089036ae84", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8", + "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8", "shasum": "" }, "require": { @@ -4092,8 +4088,8 @@ "psr/http-message": "^1.0|^2.0", "psr/log": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.0", - "symfony/console": "^6.0|^7.0", - "symfony/http-foundation": "^6.0|^7.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.0|^7.0|^8.0", "symfony/polyfill-php84": "^1.29" }, "require-dev": { @@ -4115,9 +4111,9 @@ "phpunit/phpunit": "^10.0|^11.0|^12.0", "singlestoredb/singlestoredb-laravel": "^1.0|^2.0", "spatie/laravel-ignition": "^2.0", - "symfony/mailer": "^6.0|^7.0", - "symfony/mime": "^6.0|^7.0", - "symfony/var-dumper": "^6.0|^7.0" + "symfony/mailer": "^6.0|^7.0|^8.0", + "symfony/mime": "^6.0|^7.0|^8.0", + "symfony/var-dumper": "^6.0|^7.0|^8.0" }, "type": "library", "extra": { @@ -4160,7 +4156,7 @@ "issues": "https://github.com/laravel/nightwatch/issues", "source": "https://github.com/laravel/nightwatch" }, - "time": "2026-03-12T04:01:23+00:00" + "time": "2026-03-18T23:25:05+00:00" }, { "name": "laravel/prompts", @@ -15674,16 +15670,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", - "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -15733,9 +15729,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-03-01T18:43:49+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", From bfa2053812af480c9ac094710f0f248819a2513d Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Wed, 18 Mar 2026 22:41:36 -0300 Subject: [PATCH 7/7] fix: linting --- .../integration-devto/src/Polling/DevToApiClient.php | 4 ++-- .../integration-devto/src/Polling/SyncDevToArticles.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app-modules/integration-devto/src/Polling/DevToApiClient.php b/app-modules/integration-devto/src/Polling/DevToApiClient.php index 5b4eb4ca..a4092c7f 100644 --- a/app-modules/integration-devto/src/Polling/DevToApiClient.php +++ b/app-modules/integration-devto/src/Polling/DevToApiClient.php @@ -12,7 +12,7 @@ public function getArticlesByOrg(string $orgSlug, int $page = 1, int $perPage = { $baseUrl = config('integration-devto.api_base_url'); - $response = Http::get("{$baseUrl}/articles", [ + $response = Http::get($baseUrl.'/articles', [ 'username' => $orgSlug, 'per_page' => $perPage, 'page' => $page, @@ -25,7 +25,7 @@ public function getArticle(int $articleId): array { $baseUrl = config('integration-devto.api_base_url'); - $response = Http::get("{$baseUrl}/articles/{$articleId}"); + $response = Http::get(sprintf('%s/articles/%d', $baseUrl, $articleId)); return $response->json() ?? []; } diff --git a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php index 3db4aa56..fe68cec3 100644 --- a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php +++ b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php @@ -36,7 +36,7 @@ public function handle(): int $totalUpdated = 0; $totalSkipped = 0; - $this->info("Syncing articles from DevTo org: {$orgSlug}"); + $this->info('Syncing articles from DevTo org: '.$orgSlug); do { $articles = $this->apiClient->getArticlesByOrg($orgSlug, $page); @@ -54,7 +54,7 @@ public function handle(): int $page++; } while (count($articles) === 30); - $this->info("Sync complete: {$totalCreated} created, {$totalUpdated} updated, {$totalSkipped} skipped"); + $this->info(sprintf('Sync complete: %d created, %d updated, %d skipped', $totalCreated, $totalUpdated, $totalSkipped)); return self::SUCCESS; } @@ -83,7 +83,7 @@ private function processArticle(array $article): string return 'skipped'; } - $externalRef = "devto:article:{$article['id']}"; + $externalRef = 'devto:article:'.$article['id']; $existingInteraction = Interaction::query() ->where('external_ref', $externalRef)