-
Notifications
You must be signed in to change notification settings - Fork 23
feat: implement streak system with XP multipliers #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d7b282f
3c791cf
32451df
e07ef90
864d009
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ); | ||
| } | ||
| } |
| 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 | ||
| ); | ||
| } | ||
| } | ||
| 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(), | ||
| ]); | ||
| } | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restrict reset query to 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| $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); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -59,6 +61,7 @@ class EventModel extends Model | |
| 'end_at', | ||
| 'location', | ||
| 'max_attendees', | ||
| 'xp_value', | ||
| 'attendees_count', | ||
| 'waitlist_count', | ||
| 'tenant_id', | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "EventModel.php" | head -20Repository: 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.phpRepository: 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.phpRepository: 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.phpRepository: 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 2Repository: 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
The method accesses 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 |
||
|
|
||
| /** @return Attribute<int, never> */ | ||
| protected function duration(): Attribute | ||
| { | ||
|
|
||
| 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 |
|---|---|---|
| @@ -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', | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.,firstOrFailfinds noCharacter, orincrementByEventAttendancefails), 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()
Note:
$character->refresh()can be dropped here —calculateMultiplier->execute()andincrementByEventAttendance()both independently callCharacter::query()->findOrFail()internally, so they always operate on the latest DB state.📝 Committable suggestion
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Up!