Skip to content

feat: interaction activity tracking module#183

Merged
danielhe4rt merged 10 commits into4.xfrom
feat/interaction-activity
Mar 25, 2026
Merged

feat: interaction activity tracking module#183
danielhe4rt merged 10 commits into4.xfrom
feat/interaction-activity

Conversation

@danielhe4rt
Copy link
Contributor

@danielhe4rt danielhe4rt commented Mar 19, 2026

Summary

  • Add Tracking subdomain with Interaction model for message/voice activity tracking
  • Add HasInteractions trait to Character model
  • Add DevTo identity provider and DevTo OAuth integration with article sync
  • Refactor activity module into Message/Voice subdomains

Commits

  • 89f2385 feat(gamification): add HasInteractions trait to Character model
  • e1215c1 feat(identity): add DevTo to IdentityProvider enum
  • 32e815b feat(integration-devto): add new module for DevTo OAuth and article sync
  • 644964b feat(activity): add Tracking subdomain with Interaction model
  • c49427e refactor(activity): restructure module into Message/Voice subdomains

Summary by CodeRabbit

  • New Features

    • Activity tracking for many interaction types (articles, PRs, mentoring, projects, referrals, reviews, calls, forums, shares, engagement, stars, messages, voice).
    • Configurable rewards system awarding coins and XP based on activity tier and engagement; some low/medium activities auto-approved.
    • Dev.to integration with OAuth and scheduled article sync (creates/updates tracked interactions).
  • Refactor

    • Message and voice modules reorganized into clearer namespaces.

@coderabbitai
Copy link

coderabbitai bot commented Mar 19, 2026

Warning

Rate limit exceeded

@danielhe4rt has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 13 minutes and 34 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c29434cd-aebe-48c9-8ab1-58888234b8d9

📥 Commits

Reviewing files that changed from the base of the PR and between 8fa64fa and caa4b9f.

📒 Files selected for processing (2)
  • app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php
  • app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php
📝 Walkthrough

Walkthrough

Adds a new activity tracking subsystem: Interaction Eloquent model, migration, factory, enums (ActivityType, ActivityStatus, ValueTier), DTOs, events, actions (ClassifyActivity, CalculateReward, TrackActivity, ApproveInteraction, RejectInteraction), configuration (activity-tracking.php), trait HasInteractions, message/voice namespace reorganization, Dev.to integration (OAuth client, API client, SyncDevToArticles command), IdentityProvider::DevTo, service provider wiring, and accompanying unit and feature tests.

Possibly related issues

  • he4rt/heartdevs.com issue 182 — Implements the activity-tracking domain and Dev.to integration described by the issue (Interaction model, tracking actions, config, migration, Dev.to OAuth/API client, and sync command).

Possibly related PRs

  • he4rt/heartdevs.com PR 179 — Overlaps on IdentityProvider enum and integration module structure changes relevant to adding Dev.to support.
  • he4rt/heartdevs.com PR 187 — Related to polymorphic model_type/morph discriminator handling which connects to Interaction.source polymorphism and model mapping in this PR.

Suggested reviewers

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

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: interaction activity tracking module' directly and clearly describes the main feature being added—a new interaction tracking system for the activity module.

✏️ 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

@ianarnold ianarnold left a comment

Choose a reason for hiding this comment

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

LGTM 😎

Clintonrocha98
Clintonrocha98 previously approved these changes Mar 19, 2026
Copy link
Collaborator

@Clintonrocha98 Clintonrocha98 left a comment

Choose a reason for hiding this comment

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

PR analisado com atenção e aprovado. LGTM (Looks Good To Me).

As mudanças estão alinhadas com o objetivo da tarefa, apresentam boa clareza na implementação e respeitam as convenções adotadas no projeto. A estrutura do código facilita o entendimento, e não foram encontrados pontos críticos ou inconsistências que impeçam a integração.

A solução demonstra cuidado com organização e manutenibilidade, sem indícios de regressões no comportamento esperado. Código validado e pronto para merge.

Copy link

@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: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php (1)

39-42: ⚠️ Potential issue | 🟡 Minor

Typo in label: "Obteined" should be "Obtained".

✏️ Proposed fix
             TextColumn::make('obtained_experience')
-                ->label('Obteined XP')
+                ->label('Obtained XP')
                 ->numeric()
                 ->sortable(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php`
around lines 39 - 42, Typo in the Filament column label: update the TextColumn
declaration for obtained_experience
(TextColumn::make('obtained_experience')->label(...)) in MessagesTable.php to
change the label from "Obteined XP" to "Obtained XP" so the UI shows the correct
spelling.
app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php (1)

33-35: ⚠️ Potential issue | 🟡 Minor

Typo in label: "Chanel" should be "Channel".

✏️ Proposed fix
             TextInput::make('channel_id')
-                ->label('Chanel')
+                ->label('Channel')
                 ->nullable(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php`
around lines 33 - 35, The TextInput field defined by
TextInput::make('channel_id') has a typo in its label (->label('Chanel')), so
update the label text to the correct spelling "Channel" by changing the label
call on the channel_id TextInput to ->label('Channel').
app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php (1)

24-24: ⚠️ Potential issue | 🟡 Minor

Typo: "Gamefication" should be "Gamification".

The navigation group label contains a spelling error.

📝 Proposed fix
-    protected static string|UnitEnum|null $navigationGroup = 'Gamefication';
+    protected static string|UnitEnum|null $navigationGroup = 'Gamification';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php`
at line 24, Fix the typo in the MessageResource class by updating the protected
static property navigationGroup (protected static string|UnitEnum|null
$navigationGroup) value from 'Gamefication' to 'Gamification' so the navigation
group label is spelled correctly.
🧹 Nitpick comments (11)
app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php (1)

12-21: Assert persisted state, not only returned instance.

To ensure RejectInteraction actually saves, reload the model and assert values from DB-backed state.

Suggested test hardening
 test('rejects interaction', function (): void {
     $interaction = Interaction::factory()->create([
         'status' => ActivityStatus::Pending,
     ]);

     $result = resolve(RejectInteraction::class)->handle($interaction);
+    $interaction->refresh();

     expect($result->status)->toBe(ActivityStatus::Rejected)
-        ->and($result->reviewed_at)->not->toBeNull();
+        ->and($result->reviewed_at)->not->toBeNull()
+        ->and($interaction->status)->toBe(ActivityStatus::Rejected)
+        ->and($interaction->reviewed_at)->not->toBeNull();
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php` around
lines 12 - 21, The test currently only asserts the returned instance from
RejectInteraction::handle; update it to assert persisted DB state by reloading
the Interaction model after calling
resolve(RejectInteraction::class)->handle($interaction) (e.g.,
$interaction->refresh() or Interaction::find($interaction->id)) and then assert
that the reloaded model's status is ActivityStatus::Rejected and reviewed_at is
not null to ensure the change was saved to the database.
app-modules/integration-devto/config/integration-devto.php (1)

8-8: Harden polling interval config against invalid env values.

DEVTO_POLLING_INTERVAL should be normalized to a positive integer to avoid scheduler misconfiguration.

Suggested fix
-    'polling_interval_minutes' => env('DEVTO_POLLING_INTERVAL', 30),
+    'polling_interval_minutes' => max(1, (int) env('DEVTO_POLLING_INTERVAL', 30)),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/integration-devto/config/integration-devto.php` at line 8, The
polling_interval_minutes config uses env('DEVTO_POLLING_INTERVAL', 30) without
validation; normalize and enforce a positive integer by reading the env value,
casting/parsing it to an integer and falling back to the default when
missing/invalid or non-positive (e.g., use intval/ (int) cast and max(1, $value)
or is_numeric check), then assign that sanitized value to
'polling_interval_minutes' instead of trusting the raw env call.
app-modules/activity/src/Message/Http/Controllers/MessagesController.php (1)

26-34: Consider separating voice functionality into its own controller.

The MessagesController under the Message namespace handles both text messages and voice messages. Since voice functionality has been moved to its own Voice subdomain (as evidenced by the imports from He4rt\Activity\Voice\...), consider moving postVoiceMessage to a dedicated VoiceMessagesController in the Voice namespace for better separation of concerns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/src/Message/Http/Controllers/MessagesController.php`
around lines 26 - 34, The postVoiceMessage method (CreateVoiceMessageRequest,
NewVoiceMessage) currently lives in MessagesController and mixes voice subdomain
concerns; move this method into a new VoiceMessagesController inside the Voice
namespace (e.g., He4rt\Activity\Voice\Http\Controllers) and update routing to
point to VoiceMessagesController::postVoiceMessage, keeping the same signature
and behavior (call NewVoiceMessage->persist($request->validated()) and return
response()->noContent()); remove the method from MessagesController and adjust
imports/usages accordingly.
app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php (1)

36-41: Consider documenting the expected reward calculation.

The assertion coins_awarded = 253 is a magic number. Adding a comment explaining how this value is derived (e.g., from peerReviewBase: 200, engagement metrics reactions: 42, bookmarks: 8, comments: 12, and coin range 100-300) would improve test readability and maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php` around
lines 36 - 41, The test uses a magic number 253 for coins_awarded/wallet balance
without explanation; update the test near the assertion on coins_awarded
(expect($result->coins_awarded)->toBe(253)) and the wallet balance check to
include a concise inline comment that documents the reward calculation (e.g.,
base peerReviewBase = 200 plus engagement contributions from
reactions/bookmarks/comments and applied coin range 100-300) so future readers
can see how 253 was derived; reference the symbols ActivityStatus::Approved,
$result->coins_awarded, and $character->fresh()->wallets()->first() when adding
the comment.
app-modules/integration-devto/src/OAuth/DevToOAuthUser.php (1)

17-19: Consider adding defensive checks for required payload fields.

payload['id'] and payload['username'] are accessed directly without null checks. If the Dev.to API returns an unexpected response structure, this will throw an unclear error. Consider validating required fields or providing clearer error messages.

🛡️ Proposed defensive validation
     public static function make(OAuthAccessDTO $credentials, array $payload): OAuthUserDTO
     {
+        if (!isset($payload['id'], $payload['username'])) {
+            throw new \InvalidArgumentException('DevTo user payload missing required fields: id, username');
+        }
+
         return new self(
             credentials: $credentials,
             providerId: (string) $payload['id'],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/integration-devto/src/OAuth/DevToOAuthUser.php` around lines 17 -
19, The mapping in DevToOAuthUser that assigns providerId and username directly
from $payload risks undefined index errors; update the code in DevToOAuthUser to
defensively validate that $payload['id'] and $payload['username'] exist and are
non-empty before using them (e.g., check isset/empty or use null-coalescing),
and if missing throw or return a clear, descriptive exception/error (include
which field is missing and reference IdentityProvider::DevTo in the message) so
callers can handle malformed Dev.to responses safely.
app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php (1)

47-49: Potential N+1 query issue with messages_count in $appends.

The messages_count accessor executes $this->messages()->count() on every model serialization. When loading multiple ExternalIdentity records, this triggers a separate COUNT query per record.

Consider using withCount('messages') when querying, and conditionally appending the attribute only when the count has been loaded:

♻️ Proposed refactor to avoid N+1 queries
     protected $appends = [
-        'messages_count',
     ];

Then load the count explicitly where needed:

ExternalIdentity::withCount('messages')->get();

Or make the accessor conditional:

     protected function getMessagesCountAttribute(): int
     {
+        if ($this->relationLoaded('messages')) {
+            return $this->messages->count();
+        }
+        if (array_key_exists('messages_count', $this->attributes)) {
+            return $this->attributes['messages_count'];
+        }
         return $this->messages()->count();
     }

Also applies to: 92-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php` around
lines 47 - 49, The messages_count accessor added via $appends causes an N+1
because getMessagesCountAttribute calls $this->messages()->count() for every
model; change the accessor (getMessagesCountAttribute) to first return
$this->attributes['messages_count'] if present (this is set by
ExternalIdentity::withCount('messages')), then fall back to
$this->relationLoaded('messages') ? $this->messages->count() : null (or only
then call $this->messages()->count() if you really need a DB hit), and remove
unconditional appends usage (or only append 'messages_count' when you explicitly
loaded it) so callers should load counts with withCount('messages') when
retrieving multiple ExternalIdentity records.
app-modules/activity/config/activity-tracking.php (1)

6-22: Consider using enum values in config keys to reduce drift risk.

Using raw strings for activity/tier names can silently diverge from enums over time. Prefer enum-backed keys/values where possible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/config/activity-tracking.php` around lines 6 - 22,
Replace raw string activity and tier names in the 'classification' array and the
'auto_approve_tiers' list with the corresponding enum values to prevent drift;
specifically, use your ActivityType enum members (e.g., ActivityType::ARTICLE,
ActivityType::PR_MERGED, etc.) as the keys for the 'classification' map and use
ActivityTier enum members (e.g., ActivityTier::HIGH, ActivityTier::MEDIUM,
ActivityTier::LOW) for the 'tier' fields and entries in 'auto_approve_tiers'
(use ->value or ::value depending on your PHP enum implementation) so the config
references the enums ActivityType and ActivityTier instead of raw strings.
app-modules/integration-devto/composer.json (1)

8-13: Move test and database namespaces to autoload-dev.

He4rt\\IntegrationDevTo\\Tests\\, He4rt\\IntegrationDevTo\\Database\\Factories\\, and He4rt\\IntegrationDevTo\\Database\\Seeders\\ are currently in autoload but should be in autoload-dev. This keeps the production autoloader lean and prevents these namespaces from being loaded when the package is used as a dependency in non-dev environments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/integration-devto/composer.json` around lines 8 - 13, The
composer.json currently lists test and database namespaces under "autoload"
which should be moved to "autoload-dev" to avoid shipping dev-only classes in
production; relocate the PSR-4 entries "He4rt\\IntegrationDevTo\\Tests\\",
"He4rt\\IntegrationDevTo\\Database\\Factories\\", and
"He4rt\\IntegrationDevTo\\Database\\Seeders\\" from the "autoload" -> "psr-4"
block into a new or existing "autoload-dev" -> "psr-4" block, ensuring JSON
syntax remains valid (commas, braces) after the change.
app-modules/activity/src/Tracking/Actions/TrackActivity.php (1)

36-50: Consider adding the interaction creation to the same transaction.

If the goal is to ensure atomicity, the Interaction::query()->create() call should also be within the transaction scope. Currently, if the auto-approval logic fails after creation, a pending-like interaction would remain.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/src/Tracking/Actions/TrackActivity.php` around lines 36
- 50, The interaction is created outside the transaction so failures later
(e.g., auto-approval) can leave a partial state; move the
Interaction::query()->create(...) into the same DB transaction scope (or pass
the active transaction/connection to the create call) used for the subsequent
logic in TrackActivity.php so creation and auto-approval are atomic; locate the
transaction block and replace the external Interaction::query()->create with a
create executed inside that transaction (or use the transaction's query
builder/connection when calling Interaction::query()).
app-modules/integration-devto/src/Polling/SyncDevToArticles.php (2)

93-103: Consider wrapping API call in try-catch to prevent single article failure from stopping sync.

If getArticle() fails for one article, the entire sync command will crash. Catching the exception and logging would allow processing to continue for other articles.

♻️ Proposed improvement
         if ($existingInteraction !== null) {
-            $articleDetails = $this->apiClient->getArticle($article['id']);
+            try {
+                $articleDetails = $this->apiClient->getArticle($article['id']);
+            } catch (\Throwable $e) {
+                Log::warning('DevTo sync: failed to fetch article details for update', [
+                    'article_id' => $article['id'],
+                    'error' => $e->getMessage(),
+                ]);
+                return 'skipped';
+            }

             $existingInteraction->update([

Apply similar error handling to line 119.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/integration-devto/src/Polling/SyncDevToArticles.php` around lines
93 - 103, In SyncDevToArticles, wrap the call to
$this->apiClient->getArticle($article['id']) and the subsequent
$existingInteraction->update(...) in a try-catch that catches exceptions from
getArticle() (and from update), logs the error with context (article id and
exception message) and continues to the next article so a single failure doesn't
stop the whole sync; apply the same try-catch pattern to the other API call
around line 119 as well so both getArticle-related failures are handled
gracefully.

41-55: Extract page size as a constant and add resilience for API failures.

The magic number 30 should be a named constant for clarity. Additionally, the pagination loop lacks error handling - if getArticlesByOrg() throws, the command fails without partial progress being saved.

♻️ Proposed improvement
+    private const PAGE_SIZE = 30;
+
     public function handle(): int
     {
         $orgSlug = config('integration-devto.org_slug');
         $page = 1;
         $totalCreated = 0;
         $totalUpdated = 0;
         $totalSkipped = 0;

         $this->info('Syncing articles from DevTo org: '.$orgSlug);

         do {
-            $articles = $this->apiClient->getArticlesByOrg($orgSlug, $page);
+            try {
+                $articles = $this->apiClient->getArticlesByOrg($orgSlug, $page);
+            } catch (\Throwable $e) {
+                Log::error('DevTo sync: failed to fetch articles', [
+                    'page' => $page,
+                    'error' => $e->getMessage(),
+                ]);
+                $this->error('Failed to fetch articles from DevTo API: '.$e->getMessage());
+                break;
+            }

             foreach ($articles as $article) {
                 $result = $this->processArticle($article);

                 match ($result) {
                     'created' => $totalCreated++,
                     'updated' => $totalUpdated++,
                     'skipped' => $totalSkipped++,
                 };
             }

             $page++;
-        } while (count($articles) === 30);
+        } while (count($articles) === self::PAGE_SIZE);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/integration-devto/src/Polling/SyncDevToArticles.php` around lines
41 - 55, Extract the magic number 30 into a named constant (e.g., private const
PAGE_SIZE = 30) and use that constant in the pagination condition instead of the
literal; wrap the API call to $this->apiClient->getArticlesByOrg($orgSlug,
$page) in a try/catch so failures don't abort the whole run—on exception, log
the error via the class logger, stop the loop (or set $articles = [] and break)
so partial progress (the $totalCreated/$totalUpdated/$totalSkipped counts from
processArticle) is preserved, and only increment $page after a successful fetch;
keep references to SyncDevToArticles, getArticlesByOrg, and processArticle to
locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php`:
- Line 26: The migration currently makes external_ref globally unique which can
cause cross-tenant/provider collisions; update the migration in
create_interactions_table (remove ->unique() from
$table->string('external_ref')->unique()->nullable()) and instead add a
composite unique index such as
$table->unique(['tenant_id','provider','external_ref']) (or whatever
tenant/provider column names are used) so uniqueness is enforced per tenant and
provider.

In `@app-modules/activity/src/Tracking/Actions/ApproveInteraction.php`:
- Around line 20-44: Wrap the entire approval flow in a DB transaction and make
it single-use by reloading and locking the Interaction row for update, verifying
its status is ActivityStatus::Pending before proceeding; then run
calculateReward->handle, credit the wallet (Credit::class with CreditDTO),
increment character experience, and set status/reviewed_at with
interaction->update inside the transaction, and dispatch InteractionApproved
only after the transaction commits (use DB::transaction with DB::afterCommit or
equivalent) and return the fresh interaction; if the status is not Pending,
abort/throw to prevent double-approve.

In `@app-modules/activity/src/Tracking/Actions/CalculateReward.php`:
- Around line 22-45: The final computed reward ($coinsAwarded) is only clamped
to the upper bound; ensure it is clamped into the full allowed range [coins_min,
coins_max] before it's used (including the branch where $engagementSnapshot is
null and when $peerReviewBase is used). Update the logic in CalculateReward
(affecting $coinsAwarded computation) to apply min(max($value,
$interaction->coins_min), $interaction->coins_max) (or equivalent) for both the
engagementSnapshot branch and the else branch so negative or too-small
peerReviewBase values cannot fall below $interaction->coins_min before being
passed to Credit.

In `@app-modules/activity/src/Tracking/Actions/RejectInteraction.php`:
- Around line 12-19: Restrict the state change in handle(Interaction
$interaction) to only update rows currently in ActivityStatus::Pending and fail
if none were affected: perform a conditional update via Interaction::where('id',
$interaction->id)->where('status', ActivityStatus::Pending)->update([ 'status'
=> ActivityStatus::Rejected, 'reviewed_at' => now() ]) and check the returned
affected-rows count; if zero, throw an exception (or return a clear failure) to
signal an invalid transition/race, otherwise return the fresh interaction
record.

In `@app-modules/activity/src/Tracking/Actions/TrackActivity.php`:
- Around line 52-67: Wrap the auto-approval block that handles reward
calculation, wallet credit and XP increment in a database transaction to ensure
atomicity: when $classification['status'] === ActivityStatus::AutoApproved,
perform $this->calculateReward->handle($interaction), find the Character
(Character::query()->findOrFail($dto->characterId)), call
resolve(Credit::class)->handle(new CreditDTO(...)) and
$character->increment('experience', ...) inside a DB::transaction (or equivalent
transactional helper) so failures roll back; consider extending the transaction
to include interaction creation by wrapping the entire handle() method if full
atomicity is desired.

In
`@app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/InteractionResource.php`:
- Line 21: The navigation group string on InteractionResource (protected static
string|UnitEnum|null $navigationGroup) is misspelled as 'Gamefication'; update
that value to the correct spelling 'Gamification' so the admin navigation
displays the proper group name.
- Line 25: The resource currently sets protected static ?string
$recordTitleAttribute = 'type' but 'type' is cast to the backed string enum
ActivityType, which can cause unreliable rendering; fix by either adding a
string accessor on the model (e.g. getTypeLabelAttribute or
getTypeDisplayAttribute that returns (string) $this->type or uses
ActivityType::from(...)->label) and point recordTitleAttribute at that accessor
(e.g. 'type_label'), or override the resource title formatter in
InteractionResource (implement a getTitle() / recordTitleUsing() style hook to
return a string from $record->type->value or $record->type->label); reference
InteractionResource::$recordTitleAttribute and the model's type attribute /
ActivityType enum when making the change.

In `@app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php`:
- Around line 19-21: The provider validation in CreateVoiceMessageRequest.php
incorrectly includes "devto" for voice messages; update the 'provider' rule (in
the validation array inside CreateVoiceMessageRequest) to remove "devto" so it
only allows the actual voice providers (e.g., 'twitch,discord'), and keep the
existing 'provider_id' and 'state' rules unchanged; if devto was intentionally
added, instead adjust the allowed 'state' values or add conditional validation
logic in CreateVoiceMessageRequest to handle non-voice providers appropriately.

In `@app-modules/activity/tests/Unit/Actions/NewMessageTest.php`:
- Around line 5-7: The test constructs NewMessage and calls persist using the
old signature and is skipped; update the test to instantiate NewMessage with its
current constructor signature, pass a NewMessageDTO instance (not an array) to
NewMessage->persist(), and update the PersistMessage mock to expect and return
values for a NewMessageDTO parameter; remove or disable the skip so the
assertions run. Specifically, replace the old NewMessage(...) construction with
the new constructor usage, create a NewMessageDTO (using the same test payload
fields), have the PersistMessage mock expect persist(NewMessageDTO $dto) and
return the expected result, then call $newMessage->persist($dto) and assert
outcomes.

In `@app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php`:
- Around line 43-48: The test titled "uses coins_min when no engagement and auto
approved" doesn't mark the Interaction as auto-approved, so update the
Interaction fixture created via Interaction::factory() to explicitly set the
status to the auto-approved value (e.g., 'approved' or the relevant constant) by
adding a 'status' => 'approved' (or Interaction::STATUS_APPROVED) entry to the
factory payload for this test; alternatively, if you intend to test the pending
case, rename the test to reflect that behavior so the test name matches the
fixture.

In `@app-modules/gamification/src/Character/Models/Character.php`:
- Line 37: Character::wallet() currently defines a HasOne Eloquent relation and
thereby overrides HasWallet::wallet(Currency $currency = Currency::Coin):
?Wallet causing the trait's currency-filtering API to be lost; fix by renaming
the relation method (e.g., to walletRelation() or walletsRelation()) and
keeping/adding a compatibility method matching HasWallet::wallet(Currency
$currency = Currency::Coin): ?Wallet that delegates to the relation (e.g., call
$this->wallets()->where('currency', $currency)->first()) or explicitly calls the
trait implementation via HasWallet::wallet($currency); update any internal call
sites to use the new relation name.

In `@app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php`:
- Around line 11-16: Validate the incoming OAuth payload in
DevToOAuthAccessDTO::make before constructing the DTO: ensure 'access_token'
exists and is a non-empty string (throw an InvalidArgumentException with a clear
message if missing), coerce/validate 'expires_in' to an int or null (reject
non-numeric values), and ensure 'refresh_token' is a string (use empty string
default only after validation); then pass the validated/typed values into the
DevToOAuthAccessDTO constructor so malformed provider responses produce a
controlled exception rather than causing downstream errors.

In `@app-modules/integration-devto/src/OAuth/DevToOAuthClient.php`:
- Around line 39-45: The getAuthenticatedUser method currently calls the Dev.to
API without checking for HTTP errors; update getAuthenticatedUser(OAuthAccessDTO
$credentials) to verify the $response status (e.g., using
$response->successful() or checking $response->failed()) before passing data to
DevToOAuthUser::make, and when the call fails throw or return a meaningful
exception including status code and response body (or log the details) so
callers receive clear error information instead of malformed user data.
- Around line 26-37: The auth method in DevToOAuthClient.php currently posts to
Dev.to and immediately calls ->json() without handling failures; update
auth(string $code) to handle HTTP/network errors by either using the HTTP
client's ->throw() before ->json() or by checking $response->successful() /
$response->ok() and throwing a meaningful exception when the request failed, and
only then pass the validated response data into DevToOAuthAccessDTO::make;
ensure you catch network exceptions (e.g., RequestException) if using try/catch
and include the error context in the thrown exception or log.

In `@app-modules/integration-devto/src/Polling/DevToApiClient.php`:
- Around line 15-31: The Dev.to API calls in getArticles and getArticle silently
return an empty array on failures; update both methods to use Laravel's Http
facade with timeout, retry, and throw so failures surface and are
observable—call Http::timeout(…)->retry(…, …)->throw()->get(...) (or equivalent)
when building the request, and remove the silent fallback that masks errors so
the methods propagate exceptions instead of returning [] on non-2xx responses;
locate these changes in the getArticles (or similar list-fetching method) and
getArticle functions shown in DevToApiClient.

---

Outside diff comments:
In
`@app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php`:
- Line 24: Fix the typo in the MessageResource class by updating the protected
static property navigationGroup (protected static string|UnitEnum|null
$navigationGroup) value from 'Gamefication' to 'Gamification' so the navigation
group label is spelled correctly.

In
`@app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php`:
- Around line 33-35: The TextInput field defined by
TextInput::make('channel_id') has a typo in its label (->label('Chanel')), so
update the label text to the correct spelling "Channel" by changing the label
call on the channel_id TextInput to ->label('Channel').

In
`@app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php`:
- Around line 39-42: Typo in the Filament column label: update the TextColumn
declaration for obtained_experience
(TextColumn::make('obtained_experience')->label(...)) in MessagesTable.php to
change the label from "Obteined XP" to "Obtained XP" so the UI shows the correct
spelling.

---

Nitpick comments:
In `@app-modules/activity/config/activity-tracking.php`:
- Around line 6-22: Replace raw string activity and tier names in the
'classification' array and the 'auto_approve_tiers' list with the corresponding
enum values to prevent drift; specifically, use your ActivityType enum members
(e.g., ActivityType::ARTICLE, ActivityType::PR_MERGED, etc.) as the keys for the
'classification' map and use ActivityTier enum members (e.g.,
ActivityTier::HIGH, ActivityTier::MEDIUM, ActivityTier::LOW) for the 'tier'
fields and entries in 'auto_approve_tiers' (use ->value or ::value depending on
your PHP enum implementation) so the config references the enums ActivityType
and ActivityTier instead of raw strings.

In `@app-modules/activity/src/Message/Http/Controllers/MessagesController.php`:
- Around line 26-34: The postVoiceMessage method (CreateVoiceMessageRequest,
NewVoiceMessage) currently lives in MessagesController and mixes voice subdomain
concerns; move this method into a new VoiceMessagesController inside the Voice
namespace (e.g., He4rt\Activity\Voice\Http\Controllers) and update routing to
point to VoiceMessagesController::postVoiceMessage, keeping the same signature
and behavior (call NewVoiceMessage->persist($request->validated()) and return
response()->noContent()); remove the method from MessagesController and adjust
imports/usages accordingly.

In `@app-modules/activity/src/Tracking/Actions/TrackActivity.php`:
- Around line 36-50: The interaction is created outside the transaction so
failures later (e.g., auto-approval) can leave a partial state; move the
Interaction::query()->create(...) into the same DB transaction scope (or pass
the active transaction/connection to the create call) used for the subsequent
logic in TrackActivity.php so creation and auto-approval are atomic; locate the
transaction block and replace the external Interaction::query()->create with a
create executed inside that transaction (or use the transaction's query
builder/connection when calling Interaction::query()).

In `@app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php`:
- Around line 36-41: The test uses a magic number 253 for coins_awarded/wallet
balance without explanation; update the test near the assertion on coins_awarded
(expect($result->coins_awarded)->toBe(253)) and the wallet balance check to
include a concise inline comment that documents the reward calculation (e.g.,
base peerReviewBase = 200 plus engagement contributions from
reactions/bookmarks/comments and applied coin range 100-300) so future readers
can see how 253 was derived; reference the symbols ActivityStatus::Approved,
$result->coins_awarded, and $character->fresh()->wallets()->first() when adding
the comment.

In `@app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php`:
- Around line 12-21: The test currently only asserts the returned instance from
RejectInteraction::handle; update it to assert persisted DB state by reloading
the Interaction model after calling
resolve(RejectInteraction::class)->handle($interaction) (e.g.,
$interaction->refresh() or Interaction::find($interaction->id)) and then assert
that the reloaded model's status is ActivityStatus::Rejected and reviewed_at is
not null to ensure the change was saved to the database.

In `@app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php`:
- Around line 47-49: The messages_count accessor added via $appends causes an
N+1 because getMessagesCountAttribute calls $this->messages()->count() for every
model; change the accessor (getMessagesCountAttribute) to first return
$this->attributes['messages_count'] if present (this is set by
ExternalIdentity::withCount('messages')), then fall back to
$this->relationLoaded('messages') ? $this->messages->count() : null (or only
then call $this->messages()->count() if you really need a DB hit), and remove
unconditional appends usage (or only append 'messages_count' when you explicitly
loaded it) so callers should load counts with withCount('messages') when
retrieving multiple ExternalIdentity records.

In `@app-modules/integration-devto/composer.json`:
- Around line 8-13: The composer.json currently lists test and database
namespaces under "autoload" which should be moved to "autoload-dev" to avoid
shipping dev-only classes in production; relocate the PSR-4 entries
"He4rt\\IntegrationDevTo\\Tests\\",
"He4rt\\IntegrationDevTo\\Database\\Factories\\", and
"He4rt\\IntegrationDevTo\\Database\\Seeders\\" from the "autoload" -> "psr-4"
block into a new or existing "autoload-dev" -> "psr-4" block, ensuring JSON
syntax remains valid (commas, braces) after the change.

In `@app-modules/integration-devto/config/integration-devto.php`:
- Line 8: The polling_interval_minutes config uses env('DEVTO_POLLING_INTERVAL',
30) without validation; normalize and enforce a positive integer by reading the
env value, casting/parsing it to an integer and falling back to the default when
missing/invalid or non-positive (e.g., use intval/ (int) cast and max(1, $value)
or is_numeric check), then assign that sanitized value to
'polling_interval_minutes' instead of trusting the raw env call.

In `@app-modules/integration-devto/src/OAuth/DevToOAuthUser.php`:
- Around line 17-19: The mapping in DevToOAuthUser that assigns providerId and
username directly from $payload risks undefined index errors; update the code in
DevToOAuthUser to defensively validate that $payload['id'] and
$payload['username'] exist and are non-empty before using them (e.g., check
isset/empty or use null-coalescing), and if missing throw or return a clear,
descriptive exception/error (include which field is missing and reference
IdentityProvider::DevTo in the message) so callers can handle malformed Dev.to
responses safely.

In `@app-modules/integration-devto/src/Polling/SyncDevToArticles.php`:
- Around line 93-103: In SyncDevToArticles, wrap the call to
$this->apiClient->getArticle($article['id']) and the subsequent
$existingInteraction->update(...) in a try-catch that catches exceptions from
getArticle() (and from update), logs the error with context (article id and
exception message) and continues to the next article so a single failure doesn't
stop the whole sync; apply the same try-catch pattern to the other API call
around line 119 as well so both getArticle-related failures are handled
gracefully.
- Around line 41-55: Extract the magic number 30 into a named constant (e.g.,
private const PAGE_SIZE = 30) and use that constant in the pagination condition
instead of the literal; wrap the API call to
$this->apiClient->getArticlesByOrg($orgSlug, $page) in a try/catch so failures
don't abort the whole run—on exception, log the error via the class logger, stop
the loop (or set $articles = [] and break) so partial progress (the
$totalCreated/$totalUpdated/$totalSkipped counts from processArticle) is
preserved, and only increment $page after a successful fetch; keep references to
SyncDevToArticles, getArticlesByOrg, and processArticle to locate the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5c21423f-48e8-48a9-bd8c-a0262f536ea1

📥 Commits

Reviewing files that changed from the base of the PR and between 7f1da31 and bfa2053.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (71)
  • app-modules/activity/config/activity-tracking.php
  • app-modules/activity/database/factories/InteractionFactory.php
  • app-modules/activity/database/factories/MessageFactory.php
  • app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php
  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/src/Message/Actions/NewMessage.php
  • app-modules/activity/src/Message/Actions/PersistMessage.php
  • app-modules/activity/src/Message/DTOs/NewMessageDTO.php
  • app-modules/activity/src/Message/Filament/Admin/Resources/Messages/MessageResource.php
  • app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/CreateMessage.php
  • app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/EditMessage.php
  • app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Pages/ListMessages.php
  • app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Schemas/MessageForm.php
  • app-modules/activity/src/Message/Filament/Admin/Resources/Messages/Tables/MessagesTable.php
  • app-modules/activity/src/Message/Http/Controllers/MessagesController.php
  • app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php
  • app-modules/activity/src/Message/Models/Message.php
  • app-modules/activity/src/Providers/ActivityServiceProvider.php
  • app-modules/activity/src/Tracking/Actions/ApproveInteraction.php
  • app-modules/activity/src/Tracking/Actions/CalculateReward.php
  • app-modules/activity/src/Tracking/Actions/ClassifyActivity.php
  • app-modules/activity/src/Tracking/Actions/RejectInteraction.php
  • app-modules/activity/src/Tracking/Actions/TrackActivity.php
  • app-modules/activity/src/Tracking/Concerns/HasInteractions.php
  • app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php
  • app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php
  • app-modules/activity/src/Tracking/Enums/ActivityStatus.php
  • app-modules/activity/src/Tracking/Enums/ActivityType.php
  • app-modules/activity/src/Tracking/Enums/ValueTier.php
  • app-modules/activity/src/Tracking/Events/InteractionApproved.php
  • app-modules/activity/src/Tracking/Events/InteractionTracked.php
  • app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/InteractionResource.php
  • app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ListInteractions.php
  • app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Pages/ViewInteraction.php
  • app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/Tables/InteractionsTable.php
  • app-modules/activity/src/Tracking/Models/Interaction.php
  • app-modules/activity/src/Voice/Actions/NewVoiceMessage.php
  • app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php
  • app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php
  • app-modules/activity/src/Voice/Models/Voice.php
  • app-modules/activity/tests/Feature/Filament/Admin/Message/CreateMessageTest.php
  • app-modules/activity/tests/Feature/Filament/Admin/Message/DeleteMessageTest.php
  • app-modules/activity/tests/Feature/Filament/Admin/Message/EditMessageTest.php
  • app-modules/activity/tests/Feature/Filament/Admin/Message/ListMessageTest.phpTest.php
  • app-modules/activity/tests/Feature/Filament/Admin/Message/MessageResourceTest.php
  • app-modules/activity/tests/Unit/Actions/NewMessageTest.php
  • app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php
  • app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php
  • app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php
  • app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php
  • app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php
  • app-modules/bot-discord/src/Events/MessageReceivedEvent.php
  • app-modules/gamification/src/Character/Models/Character.php
  • app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php
  • app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php
  • app-modules/identity/src/Tenant/Models/Tenant.php
  • app-modules/identity/tests/Feature/FindProfileTest.php
  • app-modules/identity/tests/Feature/UpdateProfileTest.php
  • app-modules/integration-devto/composer.json
  • app-modules/integration-devto/config/integration-devto.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthClient.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthUser.php
  • app-modules/integration-devto/src/Polling/DevToApiClient.php
  • app-modules/integration-devto/src/Polling/SyncDevToArticles.php
  • app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php
  • app-modules/integration-devto/tests/Feature/DevToOAuthTest.php
  • app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php
  • composer.json
  • config/services.php
  • database/seeders/BaseSeeder.php

@gvieira18 gvieira18 linked an issue Mar 19, 2026 that may be closed by this pull request
gvieira18
gvieira18 previously approved these changes Mar 20, 2026
- 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
- 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
- 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
- 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
- Add HasInteractions trait for interactions relationship
- Use trait in Character model for activity tracking integration
Move InteractionResource to panel-admin module, fix activity subdomain
imports across panel-admin, adapt DevTo integration to use metadata->username
instead of direct column, and load activity-tracking config.
@danielhe4rt danielhe4rt dismissed stale reviews from gvieira18 and Clintonrocha98 via c428109 March 25, 2026 21:37
@danielhe4rt danielhe4rt force-pushed the feat/interaction-activity branch from bfa2053 to c428109 Compare March 25, 2026 21:37
- Make ApproveInteraction atomic with DB transaction and pessimistic
  lock to prevent double-crediting on concurrent approvals
- Guard both approve and reject actions to only operate on pending
  interactions
- Scope external_ref unique constraint to tenant+provider to avoid
  cross-tenant collisions
- Remove devto from voice message provider validation (no voice support)
- Update ValueTier colors: red for High, yellow for Medium (reviewer
  feedback)
Copy link

@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: 3

♻️ Duplicate comments (2)
app-modules/activity/src/Tracking/Actions/CalculateReward.php (1)

22-45: ⚠️ Potential issue | 🟠 Major

Clamp rewards to the full configured range.

A low or negative peerReviewBase can still push coins_awarded below coins_min in both branches here. Clamp the final value into [coins_min, coins_max] before persisting it.

Suggested fix
         $metadata = $interaction->metadata ?? [];
         $engagementSnapshot = $metadata['engagement_snapshot'] ?? null;
+        $clampReward = fn (int $value): int => max(
+            $interaction->coins_min,
+            min($value, $interaction->coins_max),
+        );

         if ($engagementSnapshot !== null) {
             $base = $peerReviewBase ?? (int) (($interaction->coins_min + $interaction->coins_max) / 2);
@@
-            $coinsAwarded = min($base + $engagementBonus, $interaction->coins_max);
+            $coinsAwarded = $clampReward($base + $engagementBonus);
         } else {
             $coinsAwarded = $peerReviewBase !== null
-                ? min($peerReviewBase, $interaction->coins_max)
+                ? $clampReward($peerReviewBase)
                 : $interaction->coins_min;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/src/Tracking/Actions/CalculateReward.php` around lines
22 - 45, In CalculateReward, the final coinsAwarded can still fall outside the
configured range when peerReviewBase is low/negative; after computing
coinsAwarded in both the engagementSnapshot branch and the else branch, clamp it
to the interaction bounds by ensuring coinsAwarded = max(interaction->coins_min,
min(coinsAwarded, interaction->coins_max)) (or equivalent) before persisting or
returning it so the value always lies within [coins_min, coins_max]; update
references where coinsAwarded is used/saved to use the clamped value.
app-modules/activity/src/Tracking/Actions/TrackActivity.php (1)

36-69: ⚠️ Potential issue | 🟠 Major

Make interaction creation and auto-approval atomic.

If any step after the insert fails, the interaction is already persisted and CalculateReward may already have saved reward fields before the wallet credit or XP increment completes. Wrap creation plus the auto-approved side effects in one transaction, then dispatch InteractionTracked after commit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/activity/src/Tracking/Actions/TrackActivity.php` around lines 36
- 69, Wrap the Interaction creation plus its auto-approval side effects in a
single DB transaction so either all of creation, reward calculation/crediting,
and XP increment succeed or none do, and only dispatch InteractionTracked after
the transaction commits; specifically, move the Interaction::query()->create
call and the block that checks ActivityStatus::AutoApproved (including
$this->calculateReward->handle($interaction), resolving
Credit::class->handle(...), and $character->increment(...)) into a
DB::transaction (or equivalent) and use a post-commit callback (e.g.,
afterCommit or dispatching the event after the transaction returns) to call
event(new InteractionTracked($interaction)). Ensure you still load Character via
Character::query()->findOrFail and getOrCreateWallet() inside the transaction so
the wallet credit happens atomically with the interaction creation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app-modules/activity/src/Tracking/Actions/TrackActivity.php`:
- Around line 24-30: The dedupe check in TrackActivity that uses
Interaction::query()->where(...)->exists() before create is racy; replace the
two-step exists()+create() with a single atomic operation (e.g.
Interaction::firstOrCreate(['external_ref' => $dto->externalRef], $attributes)
or use Interaction::insertOrIgnore([...]) and then check the insertion result)
and/or catch the unique constraint QueryException (SQLSTATE 23000) around
Interaction::create(...) to treat the duplicate-key error as “already tracked”
and return null; apply the same change to the second duplicate-check block
(lines ~36-50) so both dedupe paths are atomic and race-safe.

In `@app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php`:
- Around line 13-23: Add a PHPDoc `@param` annotation to the TrackActivityDTO
constructor to document the metadata shape: update the docblock for the
__construct method in class TrackActivityDTO to include "@param
array<string,mixed>|null $metadata" (matching the existing ?array $metadata
parameter) so static analysis (PhpStan) recognizes the key/value types used
across payloads like engagement_snapshot, reactions, comments, bookmarks, etc.

In `@app-modules/activity/src/Tracking/Models/Interaction.php`:
- Around line 21-43: Add the missing docblock annotation and tighten the
metadata type: update the Interaction model's class-level PHPDoc to include the
use annotation /** `@use` HasFactory<InteractionFactory> */ immediately above the
use HasFactory; line and change the `@property` for $metadata from array|null to
array<string, mixed>|null so it matches project typing; keep all other docblock
properties intact and ensure the annotation references the InteractionFactory
type and the class name Interaction.

---

Duplicate comments:
In `@app-modules/activity/src/Tracking/Actions/CalculateReward.php`:
- Around line 22-45: In CalculateReward, the final coinsAwarded can still fall
outside the configured range when peerReviewBase is low/negative; after
computing coinsAwarded in both the engagementSnapshot branch and the else
branch, clamp it to the interaction bounds by ensuring coinsAwarded =
max(interaction->coins_min, min(coinsAwarded, interaction->coins_max)) (or
equivalent) before persisting or returning it so the value always lies within
[coins_min, coins_max]; update references where coinsAwarded is used/saved to
use the clamped value.

In `@app-modules/activity/src/Tracking/Actions/TrackActivity.php`:
- Around line 36-69: Wrap the Interaction creation plus its auto-approval side
effects in a single DB transaction so either all of creation, reward
calculation/crediting, and XP increment succeed or none do, and only dispatch
InteractionTracked after the transaction commits; specifically, move the
Interaction::query()->create call and the block that checks
ActivityStatus::AutoApproved (including
$this->calculateReward->handle($interaction), resolving
Credit::class->handle(...), and $character->increment(...)) into a
DB::transaction (or equivalent) and use a post-commit callback (e.g.,
afterCommit or dispatching the event after the transaction returns) to call
event(new InteractionTracked($interaction)). Ensure you still load Character via
Character::query()->findOrFail and getOrCreateWallet() inside the transaction so
the wallet credit happens atomically with the interaction creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cc830aa9-199a-46bf-8d84-2c9f5935e02e

📥 Commits

Reviewing files that changed from the base of the PR and between bfa2053 and c428109.

📒 Files selected for processing (53)
  • app-modules/activity/config/activity-tracking.php
  • app-modules/activity/database/factories/InteractionFactory.php
  • app-modules/activity/database/factories/MessageFactory.php
  • app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php
  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/src/Message/Actions/NewMessage.php
  • app-modules/activity/src/Message/Actions/PersistMessage.php
  • app-modules/activity/src/Message/DTOs/NewMessageDTO.php
  • app-modules/activity/src/Message/Http/Controllers/MessagesController.php
  • app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php
  • app-modules/activity/src/Message/Models/Message.php
  • app-modules/activity/src/Providers/ActivityServiceProvider.php
  • app-modules/activity/src/Tracking/Actions/ApproveInteraction.php
  • app-modules/activity/src/Tracking/Actions/CalculateReward.php
  • app-modules/activity/src/Tracking/Actions/ClassifyActivity.php
  • app-modules/activity/src/Tracking/Actions/RejectInteraction.php
  • app-modules/activity/src/Tracking/Actions/TrackActivity.php
  • app-modules/activity/src/Tracking/Concerns/HasInteractions.php
  • app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php
  • app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php
  • app-modules/activity/src/Tracking/Enums/ActivityStatus.php
  • app-modules/activity/src/Tracking/Enums/ActivityType.php
  • app-modules/activity/src/Tracking/Enums/ValueTier.php
  • app-modules/activity/src/Tracking/Events/InteractionApproved.php
  • app-modules/activity/src/Tracking/Events/InteractionTracked.php
  • app-modules/activity/src/Tracking/Models/Interaction.php
  • app-modules/activity/src/Voice/Actions/NewVoiceMessage.php
  • app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php
  • app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php
  • app-modules/activity/src/Voice/Models/Voice.php
  • app-modules/activity/tests/Unit/Actions/NewMessageTest.php
  • app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php
  • app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php
  • app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php
  • app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php
  • app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php
  • app-modules/bot-discord/src/Events/MessageReceivedEvent.php
  • app-modules/gamification/src/Character/Models/Character.php
  • app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php
  • app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php
  • app-modules/identity/src/Tenant/Models/Tenant.php
  • app-modules/identity/tests/Feature/FindProfileTest.php
  • app-modules/identity/tests/Feature/UpdateProfileTest.php
  • app-modules/integration-devto/composer.json
  • app-modules/integration-devto/config/integration-devto.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthClient.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthUser.php
  • app-modules/integration-devto/src/Polling/DevToApiClient.php
  • app-modules/integration-devto/src/Polling/SyncDevToArticles.php
  • app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php
  • app-modules/integration-devto/tests/Feature/DevToOAuthTest.php
  • app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php
✅ Files skipped from review due to trivial changes (22)
  • app-modules/identity/src/Tenant/Models/Tenant.php
  • app-modules/identity/tests/Feature/UpdateProfileTest.php
  • app-modules/bot-discord/src/Events/MessageReceivedEvent.php
  • app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php
  • app-modules/activity/src/Tracking/Events/InteractionApproved.php
  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php
  • app-modules/activity/src/Message/DTOs/NewMessageDTO.php
  • app-modules/integration-devto/config/integration-devto.php
  • app-modules/integration-devto/composer.json
  • app-modules/activity/tests/Unit/Actions/NewMessageTest.php
  • app-modules/activity/src/Message/Actions/PersistMessage.php
  • app-modules/activity/src/Tracking/Enums/ActivityStatus.php
  • app-modules/activity/src/Tracking/Enums/ValueTier.php
  • app-modules/activity/src/Message/Http/Controllers/MessagesController.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthUser.php
  • app-modules/activity/config/activity-tracking.php
  • app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php
  • app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php
  • app-modules/activity/src/Tracking/Enums/ActivityType.php
  • app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php
  • app-modules/activity/src/Message/Models/Message.php
🚧 Files skipped from review as they are similar to previous changes (26)
  • app-modules/activity/src/Tracking/Events/InteractionTracked.php
  • app-modules/activity/src/Voice/Models/Voice.php
  • app-modules/identity/tests/Feature/FindProfileTest.php
  • app-modules/activity/database/factories/MessageFactory.php
  • app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php
  • app-modules/activity/src/Tracking/Concerns/HasInteractions.php
  • app-modules/activity/src/Voice/Actions/NewVoiceMessage.php
  • app-modules/activity/src/Tracking/Actions/RejectInteraction.php
  • app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php
  • app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php
  • app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php
  • app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php
  • app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php
  • app-modules/activity/src/Tracking/Actions/ApproveInteraction.php
  • app-modules/integration-devto/tests/Feature/DevToOAuthTest.php
  • app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php
  • app-modules/activity/database/factories/InteractionFactory.php
  • app-modules/integration-devto/src/Polling/DevToApiClient.php
  • app-modules/activity/src/Tracking/Actions/ClassifyActivity.php
  • app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php
  • app-modules/integration-devto/src/OAuth/DevToOAuthClient.php
  • app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php
  • app-modules/integration-devto/src/Polling/SyncDevToArticles.php
  • app-modules/activity/src/Message/Actions/NewMessage.php
  • app-modules/activity/src/Providers/ActivityServiceProvider.php

Add generic types to HasFactory, specify iterable value types for
metadata arrays, and remove unused Wallet import from Character model.
@danielhe4rt danielhe4rt merged commit 5e93e54 into 4.x Mar 25, 2026
6 checks passed
@danielhe4rt danielhe4rt deleted the feat/interaction-activity branch March 25, 2026 21:56
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.

[feature request] activity tracking + devto integration

5 participants