Skip to content
Merged
34 changes: 34 additions & 0 deletions app-modules/activity/config/activity-tracking.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

return [
'classification' => [
'article' => ['tier' => 'high', 'coins_min' => 100, 'coins_max' => 300],
'pr_merged' => ['tier' => 'high', 'coins_min' => 80, 'coins_max' => 250],
'mentoring' => ['tier' => 'high', 'coins_min' => 50, 'coins_max' => 150],
'squad_project' => ['tier' => 'high', 'coins_min' => 200, 'coins_max' => 500],
'referral' => ['tier' => 'medium', 'coins_min' => 20, 'coins_max' => 30],
'peer_review' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 25],
'call_participation' => ['tier' => 'medium', 'coins_min' => 15, 'coins_max' => 30],
'forum_debate' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 20],
'content_share' => ['tier' => 'low', 'coins_min' => 5, 'coins_max' => 10],
'engagement' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3],
'repo_star' => ['tier' => 'low', 'coins_min' => 2, 'coins_max' => 2],
'message' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 2],
'voice' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3],
],

'auto_approve_tiers' => ['low', 'medium'],

'engagement_formula' => [
'reactions_multiplier' => 0.5,
'reactions_cap' => 25,
'bookmarks_multiplier' => 1.0,
'bookmarks_cap' => 15,
'comments_multiplier' => 2.0,
'comments_cap' => 30,
],

'xp_multiplier' => 1,
];
69 changes: 69 additions & 0 deletions app-modules/activity/database/factories/InteractionFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Database\Factories;

use He4rt\Activity\Tracking\Enums\ActivityStatus;
use He4rt\Activity\Tracking\Enums\ActivityType;
use He4rt\Activity\Tracking\Enums\ValueTier;
use He4rt\Activity\Tracking\Models\Interaction;
use He4rt\Gamification\Character\Models\Character;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use He4rt\Identity\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<Interaction>
*/
final class InteractionFactory extends Factory
{
protected $model = Interaction::class;

public function definition(): array
{
return [
'character_id' => Character::factory(),
'tenant_id' => Tenant::factory(),
'type' => ActivityType::Article,
'provider' => IdentityProvider::DevTo,
'value_tier' => ValueTier::High,
'coins_min' => 100,
'coins_max' => 300,
'status' => ActivityStatus::Pending,
'occurred_at' => now(),
];
}

public function autoApproved(): self
{
return $this->state([
'status' => ActivityStatus::AutoApproved,
'type' => ActivityType::Engagement,
'value_tier' => ValueTier::Low,
'coins_min' => 1,
'coins_max' => 3,
]);
}

public function approved(): self
{
return $this->state([
'status' => ActivityStatus::Approved,
'reviewed_at' => now(),
]);
}

public function withEngagement(int $reactions = 0, int $comments = 0, int $bookmarks = 0): self
{
return $this->state([
'metadata' => [
'engagement_snapshot' => [
'reactions' => $reactions,
'comments' => $comments,
'bookmarks' => $bookmarks,
],
],
]);
}
}
2 changes: 1 addition & 1 deletion app-modules/activity/database/factories/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace He4rt\Activity\Database\Factories;

use He4rt\Activity\Models\Message;
use He4rt\Activity\Message\Models\Message;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('interactions', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('character_id')->constrained('characters');
$table->foreignId('tenant_id')->constrained('tenants');
$table->string('type');
$table->string('provider');
$table->string('value_tier');
$table->integer('coins_min');
$table->integer('coins_max');
$table->integer('coins_awarded')->nullable();
$table->integer('xp_awarded')->nullable();
$table->string('status')->default('pending');
$table->nullableUuidMorphs('source');
$table->string('external_ref')->nullable();
$table->jsonb('metadata')->nullable();
$table->timestamp('occurred_at');
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();

$table->index(['character_id', 'type', 'created_at'], 'idx_interactions_character_type');
$table->index(['status', 'value_tier'], 'idx_interactions_status_tier');
$table->index(['tenant_id', 'occurred_at'], 'idx_interactions_tenant');
$table->unique(['tenant_id', 'provider', 'external_ref'], 'uniq_interactions_tenant_provider_ref');
});
}

public function down(): void
{
Schema::dropIfExists('interactions');
}
};
2 changes: 1 addition & 1 deletion app-modules/activity/routes/message-routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use App\Http\Middleware\BotAuthentication;
use App\Http\Middleware\VerifyIfHasTenantProviderMiddleware;
use He4rt\Activity\Http\Controllers\MessagesController;
use He4rt\Activity\Message\Http\Controllers\MessagesController;
use Illuminate\Support\Facades\Route;

Route::prefix('api')->middleware(['api', BotAuthentication::class, VerifyIfHasTenantProviderMiddleware::class])->group(function (): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

declare(strict_types=1);

namespace He4rt\Activity\Actions;
namespace He4rt\Activity\Message\Actions;

use He4rt\Activity\DTOs\NewMessageDTO;
use He4rt\Activity\Message\DTOs\NewMessageDTO;
use He4rt\Gamification\Character\Models\Character;
use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO;
use He4rt\Identity\User\Actions\ResolveUserContext;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

declare(strict_types=1);

namespace He4rt\Activity\Actions;
namespace He4rt\Activity\Message\Actions;

use He4rt\Activity\DTOs\NewMessageDTO;
use He4rt\Activity\Models\Message;
use He4rt\Activity\Message\DTOs\NewMessageDTO;
use He4rt\Activity\Message\Models\Message;

class PersistMessage
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace He4rt\Activity\DTOs;
namespace He4rt\Activity\Message\DTOs;

use DateTimeImmutable;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

declare(strict_types=1);

namespace He4rt\Activity\Http\Controllers;
namespace He4rt\Activity\Message\Http\Controllers;

use App\Http\Controllers\Controller;
use He4rt\Activity\Actions\NewMessage;
use He4rt\Activity\Actions\NewVoiceMessage;
use He4rt\Activity\DTOs\NewMessageDTO;
use He4rt\Activity\Http\Requests\CreateMessageRequest;
use He4rt\Activity\Http\Requests\CreateVoiceMessageRequest;
use He4rt\Activity\Message\Actions\NewMessage;
use He4rt\Activity\Message\DTOs\NewMessageDTO;
use He4rt\Activity\Message\Http\Requests\CreateMessageRequest;
use He4rt\Activity\Voice\Actions\NewVoiceMessage;
use He4rt\Activity\Voice\Http\Requests\CreateVoiceMessageRequest;
use Illuminate\Http\Response;

final class MessagesController extends Controller
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace He4rt\Activity\Http\Requests;
namespace He4rt\Activity\Message\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

Expand All @@ -20,7 +20,7 @@ public function rules(): array
{
return [
'tenant_id' => ['required'],
'provider' => ['required', 'in:twitch,discord'],
'provider' => ['required', 'in:twitch,discord,devto'],
'external_account_id' => ['required'],
'provider_message_id' => ['required'],
'channel_id' => ['required'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace He4rt\Activity\Models;
namespace He4rt\Activity\Message\Models;

use He4rt\Activity\Database\Factories\MessageFactory;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

class ActivityServiceProvider extends ServiceProvider
{
public function register(): void {}
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../../config/activity-tracking.php', 'activity-tracking');
}

public function boot(): void
{
Expand Down
63 changes: 63 additions & 0 deletions app-modules/activity/src/Tracking/Actions/ApproveInteraction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Tracking\Actions;

use He4rt\Activity\Tracking\Enums\ActivityStatus;
use He4rt\Activity\Tracking\Events\InteractionApproved;
use He4rt\Activity\Tracking\Models\Interaction;
use He4rt\Economy\Actions\Credit;
use He4rt\Economy\DTOs\CreditDTO;
use He4rt\Gamification\Character\Models\Character;
use Illuminate\Support\Facades\DB;

final readonly class ApproveInteraction
{
public function __construct(
private CalculateReward $calculateReward,
) {}

public function handle(Interaction $interaction, ?int $peerReviewBase = null): Interaction
{
if ($interaction->status !== ActivityStatus::Pending) {
return $interaction;
}

return DB::transaction(function () use ($interaction, $peerReviewBase): Interaction {
$locked = Interaction::query()
->where('id', $interaction->id)
->where('status', ActivityStatus::Pending)
->lockForUpdate()
->first();

if ($locked === null) {
return $interaction->fresh();
}

$reward = $this->calculateReward->handle($locked, $peerReviewBase);

$character = Character::query()->findOrFail($locked->character_id);
$wallet = $character->getOrCreateWallet();

resolve(Credit::class)->handle(new CreditDTO(
walletId: $wallet->id,
amount: $reward['coins_awarded'],
referenceType: Interaction::class,
referenceId: $locked->id,
description: 'Reward: '.$locked->type->value,
));

$character->increment('experience', $reward['xp_awarded']);

$locked->update([
'status' => ActivityStatus::Approved,
'reviewed_at' => now(),
]);

event(new InteractionApproved($locked->fresh()));

return $locked->fresh();
});
}
}
60 changes: 60 additions & 0 deletions app-modules/activity/src/Tracking/Actions/CalculateReward.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace He4rt\Activity\Tracking\Actions;

use He4rt\Activity\Tracking\Models\Interaction;

final class CalculateReward
{
/**
* @return array{coins_awarded: int, xp_awarded: int}
*/
public function handle(Interaction $interaction, ?int $peerReviewBase = null): array
{
$engagementFormula = config('activity-tracking.engagement_formula');
$xpMultiplier = config('activity-tracking.xp_multiplier', 1);

$metadata = $interaction->metadata ?? [];
$engagementSnapshot = $metadata['engagement_snapshot'] ?? null;

if ($engagementSnapshot !== null) {
$base = $peerReviewBase ?? (int) (($interaction->coins_min + $interaction->coins_max) / 2);

$reactionsBonus = min(
($engagementSnapshot['reactions'] ?? 0) * $engagementFormula['reactions_multiplier'],
$engagementFormula['reactions_cap']
);

$bookmarksBonus = min(
($engagementSnapshot['bookmarks'] ?? 0) * $engagementFormula['bookmarks_multiplier'],
$engagementFormula['bookmarks_cap']
);

$commentsBonus = min(
($engagementSnapshot['comments'] ?? 0) * $engagementFormula['comments_multiplier'],
$engagementFormula['comments_cap']
);

$engagementBonus = (int) ($reactionsBonus + $bookmarksBonus + $commentsBonus);
$coinsAwarded = min($base + $engagementBonus, $interaction->coins_max);
} else {
$coinsAwarded = $peerReviewBase !== null
? min($peerReviewBase, $interaction->coins_max)
: $interaction->coins_min;
}

$xpAwarded = (int) ($coinsAwarded * $xpMultiplier);

$interaction->update([
'coins_awarded' => $coinsAwarded,
'xp_awarded' => $xpAwarded,
]);

return [
'coins_awarded' => $coinsAwarded,
'xp_awarded' => $xpAwarded,
];
}
}
Loading