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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app-modules/events/src/Actions/ProcessManualOverrideAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace He4rt\Events\Actions;

use He4rt\Events\Models\EventModel;
use He4rt\Gamification\Character\Actions\IncrementExperience;
use He4rt\Gamification\Character\Models\Character;

final readonly class ProcessManualOverrideAction
{
public function __construct(
private IncrementExperience $incrementExperience,
) {}

/**
* Processa override manual
* - XP base é atribuído SEM multiplicador
* - Streak NÃO é incrementado nem zerado
*/
public function execute(EventModel $event, string $userId): void
{
$character = Character::query()
->where('user_id', $userId)
->where('tenant_id', $event->tenant_id)
->firstOrFail();

$baseXp = $event->xp_value;
$this->incrementExperience->incrementByEventAttendance(
(string) $character->id,
$baseXp,
1.0
);
}
}
45 changes: 45 additions & 0 deletions app-modules/events/src/Actions/ProcessVerifiedAttendanceAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace He4rt\Events\Actions;

use He4rt\Events\Models\EventModel;
use He4rt\Gamification\Character\Actions\CalculateStreakMultiplierAction;
use He4rt\Gamification\Character\Actions\IncrementExperience;
use He4rt\Gamification\Character\Actions\IncrementStreakAction;
use He4rt\Gamification\Character\Models\Character;

final readonly class ProcessVerifiedAttendanceAction
{
public function __construct(
private VerifyAttendanceAction $verifyAttendance,
private IncrementStreakAction $incrementStreak,
private CalculateStreakMultiplierAction $calculateMultiplier,
private IncrementExperience $incrementExperience,
) {}

/**
* Processa a verificação de presença com streak e multiplicador
* Usado por GPS, Discord, Código
*/
public function execute(EventModel $event, string $userId): void
{
$this->verifyAttendance->execute($event, $userId);

$character = Character::query()
->where('user_id', $userId)
->where('tenant_id', $event->tenant_id)
->firstOrFail();

$this->incrementStreak->execute((string) $character->id);
$character->refresh();
$multiplier = $this->calculateMultiplier->execute((string) $character->id);
$baseXp = $event->xp_value;
$this->incrementExperience->incrementByEventAttendance(
(string) $character->id,
$baseXp,
$multiplier
);
}
Comment on lines +26 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Wrap the multi-step flow in a DB transaction to prevent partial state.

The five DB mutations (mark verified → fetch character → increment streak → calculate multiplier → award XP) are not wrapped in a transaction. If any step throws after verifyAttendance->execute() succeeds (e.g., firstOrFail finds no Character, or incrementByEventAttendance fails), the preceding writes are persisted while the rest are skipped — attendance gets verified without a streak increment, or a streak gets incremented without XP being awarded. Both are silent data inconsistencies that are hard to detect and recover from.

🔒 Proposed fix — wrap in DB::transaction()
+use Illuminate\Support\Facades\DB;
 ...
     public function execute(EventModel $event, string $userId): void
     {
-        $this->verifyAttendance->execute($event, $userId);
-
-        $character = Character::query()
-            ->where('user_id', $userId)
-            ->where('tenant_id', $event->tenant_id)
-            ->firstOrFail();
-
-        $this->incrementStreak->execute((string) $character->id);
-        $character->refresh();
-        $multiplier = $this->calculateMultiplier->execute((string) $character->id);
-        $baseXp = $event->xp_value;
-        $this->incrementExperience->incrementByEventAttendance(
-            (string) $character->id,
-            $baseXp,
-            $multiplier
-        );
+        DB::transaction(function () use ($event, $userId): void {
+            $this->verifyAttendance->execute($event, $userId);
+
+            $character = Character::query()
+                ->where('user_id', $userId)
+                ->where('tenant_id', $event->tenant_id)
+                ->firstOrFail();
+
+            $this->incrementStreak->execute((string) $character->id);
+            $multiplier = $this->calculateMultiplier->execute((string) $character->id);
+            $baseXp = $event->xp_value;
+            $this->incrementExperience->incrementByEventAttendance(
+                (string) $character->id,
+                $baseXp,
+                $multiplier
+            );
+        });
     }

Note: $character->refresh() can be dropped here — calculateMultiplier->execute() and incrementByEventAttendance() both independently call Character::query()->findOrFail() internally, so they always operate on the latest DB state.

📝 Committable suggestion

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

Suggested change
public function execute(EventModel $event, string $userId): void
{
$this->verifyAttendance->execute($event, $userId);
$character = Character::query()
->where('user_id', $userId)
->where('tenant_id', $event->tenant_id)
->firstOrFail();
$this->incrementStreak->execute((string) $character->id);
$character->refresh();
$multiplier = $this->calculateMultiplier->execute((string) $character->id);
$baseXp = $event->xp_value;
$this->incrementExperience->incrementByEventAttendance(
(string) $character->id,
$baseXp,
$multiplier
);
}
public function execute(EventModel $event, string $userId): void
{
DB::transaction(function () use ($event, $userId): void {
$this->verifyAttendance->execute($event, $userId);
$character = Character::query()
->where('user_id', $userId)
->where('tenant_id', $event->tenant_id)
->firstOrFail();
$this->incrementStreak->execute((string) $character->id);
$multiplier = $this->calculateMultiplier->execute((string) $character->id);
$baseXp = $event->xp_value;
$this->incrementExperience->incrementByEventAttendance(
(string) $character->id,
$baseXp,
$multiplier
);
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/events/src/Actions/ProcessVerifiedAttendanceAction.php` around
lines 26 - 44, Wrap the multi-step mutation flow inside a DB::transaction() in
ProcessVerifiedAttendanceAction::execute so the sequence
verifyAttendance->execute($event, $userId),
Character::query()->where(...)->firstOrFail(),
incrementStreak->execute((string)$character->id),
calculateMultiplier->execute((string)$character->id) and
incrementExperience->incrementByEventAttendance(...) run atomically; move
verifyAttendance->execute into the transaction and remove the unnecessary
$character->refresh() as calculateMultiplier->execute and
incrementByEventAttendance fetch the current Character state themselves so all
mutations either commit together or roll back on error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Up!

}
22 changes: 22 additions & 0 deletions app-modules/events/src/Actions/VerifyAttendanceAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace He4rt\Events\Actions;

use He4rt\Events\Models\EventModel;
use Illuminate\Support\Facades\Date;

final class VerifyAttendanceAction
{
/**
* Marca a presença como verificada para um usuário em um evento
* Isso incrementa o streak e aplica o multiplicador de XP
*/
public function execute(EventModel $event, string $userId): void
{
$event->attendees()->updateExistingPivot($userId, [
'verified_at' => Date::now(),
]);
}
}
56 changes: 56 additions & 0 deletions app-modules/events/src/Jobs/ResetUnverifiedStreaksJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace He4rt\Events\Jobs;

use He4rt\Events\Models\EventModel;
use He4rt\Gamification\Character\Actions\ResetStreakAction;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;

class ResetUnverifiedStreaksJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;

public function __construct(
private readonly int $eventId,
) {}

public function handle(): void
{
$event = EventModel::query()->findOrFail($this->eventId);

// Ignora eventos cancelados
if ($event->active === false) {
return;
}

// Busca usuários com status = attending (pré-confirmados)
// que NÃO têm verified_at (não verificaram presença)
$unverifiedAttendees = DB::table('events_attendees')
->where('event_id', $this->eventId)
->whereNotNull('status') // Que tenham status (estão confirmados)
->whereNull('verified_at') // E não verificaram presença
->get();
Comment on lines +36 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict reset query to Attending status, not any non-null status.

Lines 38–39 currently include every attendee with any status value. That can reset streaks for non-attending statuses (e.g., waitlist), which conflicts with the intended behavior.

Suggested fix
+use He4rt\Events\Enums\AttendingStatusEnum;
...
$unverifiedAttendees = DB::table('events_attendees')
    ->where('event_id', $this->eventId)
-   ->whereNotNull('status')
+   ->where('status', AttendingStatusEnum::Attending->value)
    ->whereNull('verified_at')
    ->get();
📝 Committable suggestion

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

Suggested change
$unverifiedAttendees = DB::table('events_attendees')
->where('event_id', $this->eventId)
->whereNotNull('status') // Que tenham status (estão confirmados)
->whereNull('verified_at') // E não verificaram presença
->get();
use He4rt\Events\Enums\AttendingStatusEnum;
$unverifiedAttendees = DB::table('events_attendees')
->where('event_id', $this->eventId)
->where('status', AttendingStatusEnum::Attending->value)
->whereNull('verified_at') // E não verificaram presença
->get();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/events/src/Jobs/ResetUnverifiedStreaksJob.php` around lines 36 -
40, The query that builds $unverifiedAttendees in ResetUnverifiedStreaksJob
currently uses whereNotNull('status') and therefore matches any non-null status;
change it to only match attendees with the attending status (e.g., replace
whereNotNull('status') with where('status', 'Attending') or the app's
enum/constant like AttendeeStatus::ATTENDING) so only true attendees (from the
events_attendees table) who haven't verified are selected for streak reset.


$resetStreakAction = new ResetStreakAction();

foreach ($unverifiedAttendees as $attendee) {
// Busca o character do usuário para este tenant
$character = DB::table('characters')
->where('user_id', $attendee->user_id)
->where('tenant_id', $event->tenant_id)
->first();

if ($character) {
$resetStreakAction->execute($character->id);
}
}
}
}
28 changes: 28 additions & 0 deletions app-modules/events/src/Models/EventModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Support\Facades\Date;

/**
* @property bool $active
Expand All @@ -33,6 +34,7 @@
* @property string $description
* @property string $location
* @property int $max_attendees
* @property int $xp_value
* @property int $attendees_count
* @property int $waitlist_count
* @property int $tenant_id
Expand All @@ -59,6 +61,7 @@ class EventModel extends Model
'end_at',
'location',
'max_attendees',
'xp_value',
'attendees_count',
'waitlist_count',
'tenant_id',
Expand Down Expand Up @@ -190,6 +193,31 @@ public function sponsors(): BelongsToMany
->withTimestamps();
}

public function isVerificationWindowClosed(): bool
{
return Date::now()->greaterThanOrEqualTo(
$this->end_at->copy()->addMinutes(30)
);
}

public function verifyAttendance(int $userId): void
{
$this->attendees()->updateExistingPivot($userId, [
'verified_at' => Date::now(),
]);
}

public function hasVerifiedAttendance(int $userId): bool
{
$attendee = $this->attendees()->where('user_id', $userId)->first();

if (!$attendee) {
return false;
}

return $attendee->pivot->verified_at !== null;
}
Comment on lines +210 to +219
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "EventModel.php" | head -20

Repository: he4rt/heartdevs.com

Length of output: 110


🏁 Script executed:

# Search for the attendees() relation in EventModel
rg -A 15 "function attendees" app-modules/events/src/Models/EventModel.php

Repository: he4rt/heartdevs.com

Length of output: 546


🏁 Script executed:

# Also check for hasVerifiedAttendance method
rg -A 10 "function hasVerifiedAttendance" app-modules/events/src/Models/EventModel.php

Repository: he4rt/heartdevs.com

Length of output: 332


🏁 Script executed:

# Check the full EventModel.php around the lines mentioned (210-219)
wc -l app-modules/events/src/Models/EventModel.php

Repository: he4rt/heartdevs.com

Length of output: 112


🏁 Script executed:

# Find migration files related to events_attendees
fd -t f ".*migration.*" app-modules/events | grep -i "attendee\|event"

Repository: he4rt/heartdevs.com

Length of output: 45


🏁 Script executed:

# Search for verified_at in the events module
rg "verified_at" app-modules/events/ -B 2 -A 2

Repository: he4rt/heartdevs.com

Length of output: 3557


🏁 Script executed:

# Check EventAttend model to see if it defines verified_at
rg -A 20 "class EventAttend" app-modules/events/

Repository: he4rt/heartdevs.com

Length of output: 1595


hasVerifiedAttendance() will return false negatives because verified_at is not loaded on the pivot.

The method accesses $attendee->pivot->verified_at on line 218, but the attendees() relation only declares withPivot(['status', 'attend_order']). Since verified_at is a legitimate column on the pivot table (defined in EventAttend model and used throughout the codebase), this missing field will cause the method to incorrectly report unverified attendees as verified.

Suggested fix
 public function attendees(): BelongsToMany
 {
     return $this
         ->belongsToMany(
             User::class,
             'events_attendees',
             'event_id',
             'user_id'
         )
         ->using(EventAttend::class)
-        ->withPivot(['status', 'attend_order'])
+        ->withPivot(['status', 'attend_order', 'verified_at'])
         ->withTimestamps();
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/events/src/Models/EventModel.php` around lines 210 - 219, The
hasVerifiedAttendance(int $userId) method is returning false negatives because
the attendees() relation does not include the pivot column verified_at; update
the attendees() relation (the builder used by the attendees() relationship) to
include verified_at in its withPivot(...) list so that
$attendee->pivot->verified_at is populated, then verify hasVerifiedAttendance
uses that pivot field (and consider null-safe checks) — locate the attendees()
relationship definition and add 'verified_at' to the withPivot array (refer to
hasVerifiedAttendance, attendees(), and the EventAttend pivot model).


/** @return Attribute<int, never> */
protected function duration(): Attribute
{
Expand Down
9 changes: 9 additions & 0 deletions app-modules/events/src/Models/Pivot/EventAttend.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

namespace He4rt\Events\Models\Pivot;

use Carbon\Carbon;
use He4rt\Events\Enums\AttendingStatusEnum;
use Illuminate\Database\Eloquent\Relations\Pivot;

/**
* @property AttendingStatusEnum $status
* @property int $attend_order
* @property Carbon|null $verified_at
*/
class EventAttend extends Pivot
{
Expand All @@ -18,12 +20,19 @@ class EventAttend extends Pivot
protected $fillable = [
'status',
'attend_order',
'verified_at',
];

public function isVerified(): bool
{
return $this->verified_at !== null;
}

protected function casts(): array
{
return [
'status' => AttendingStatusEnum::class,
'verified_at' => 'datetime',
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace He4rt\Gamification\Character\Actions;

use He4rt\Gamification\Character\Enums\StreakMultiplierEnum;
use He4rt\Gamification\Character\Models\Character;

final readonly class CalculateStreakMultiplierAction
{
public function execute(string $characterId): float
{
$character = Character::query()->findOrFail($characterId);
$streak = $character->streak ?? 0;

return StreakMultiplierEnum::fromStreak($streak)->getMultiplier();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,32 @@

class IncrementExperience
{
public function incrementByTextMessage(string $characterId, string $message): int
public function incrementByTextMessage(string $characterId, string $message, float $multiplier = 1.0): int
{
$character = Character::query()->findOrFail($characterId);
$experience = Character::generateTextExperience($message, $character->level, false);
$character->increment('experience', $experience);
$finalExperience = (int) ($experience * $multiplier);
$character->increment('experience', $finalExperience);

return $experience;
return $finalExperience;
}

public function incrementByVoiceMessage(string $characterId, VoiceStatesEnum $voiceState): int
public function incrementByVoiceMessage(string $characterId, VoiceStatesEnum $voiceState, float $multiplier = 1.0): int
{
$character = Character::query()->findOrFail($characterId);
$experience = $voiceState->getExperienceMultiplier() * $character->level;
$character->increment('experience', $experience);
$finalExperience = (int) ($experience * $multiplier);
$character->increment('experience', $finalExperience);

return $experience;
return $finalExperience;
}

public function incrementByEventAttendance(string $characterId, int $baseXp, float $multiplier = 1.0): int
{
$character = Character::query()->findOrFail($characterId);
$finalExperience = (int) ($baseXp * $multiplier);
$character->increment('experience', $finalExperience);

return $finalExperience;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace He4rt\Gamification\Character\Actions;

use He4rt\Gamification\Character\Models\Character;

final readonly class IncrementStreakAction
{
public function execute(string $characterId): void
{
$character = Character::query()->findOrFail($characterId);
$character->increment('streak');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace He4rt\Gamification\Character\Actions;

use He4rt\Gamification\Character\Models\Character;

final readonly class ResetStreakAction
{
public function execute(string $characterId): void
{
$character = Character::query()->findOrFail($characterId);
$character->update(['streak' => 0]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace He4rt\Gamification\Character\Enums;

use Filament\Support\Contracts\HasLabel;

enum StreakMultiplierEnum: int implements HasLabel
{
case None = 0; // 1.0x
case Bronze = 3; // 1.1x (streak 3-4)
case Silver = 5; // 1.25x (streak 5-9)
case Gold = 10; // 1.5x (streak 10+)

public static function fromStreak(int $streak): self
{
return match (true) {
$streak >= 10 => self::Gold,
$streak >= 5 => self::Silver,
$streak >= 3 => self::Bronze,
default => self::None,
};
}

public function getMultiplier(): float
{
return match ($this) {
self::None => 1.0,
self::Bronze => 1.1,
self::Silver => 1.25,
self::Gold => 1.5,
};
}

public function getLabel(): string
{
return match ($this) {
self::None => '1.0x',
self::Bronze => '1.1x',
self::Silver => '1.25x',
self::Gold => '1.5x',
};
}
}
Loading