diff --git a/app-modules/activity/config/activity-tracking.php b/app-modules/activity/config/activity-tracking.php new file mode 100644 index 00000000..bc782171 --- /dev/null +++ b/app-modules/activity/config/activity-tracking.php @@ -0,0 +1,34 @@ + [ + 'article' => ['tier' => 'high', 'coins_min' => 100, 'coins_max' => 300], + 'pr_merged' => ['tier' => 'high', 'coins_min' => 80, 'coins_max' => 250], + 'mentoring' => ['tier' => 'high', 'coins_min' => 50, 'coins_max' => 150], + 'squad_project' => ['tier' => 'high', 'coins_min' => 200, 'coins_max' => 500], + 'referral' => ['tier' => 'medium', 'coins_min' => 20, 'coins_max' => 30], + 'peer_review' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 25], + 'call_participation' => ['tier' => 'medium', 'coins_min' => 15, 'coins_max' => 30], + 'forum_debate' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 20], + 'content_share' => ['tier' => 'low', 'coins_min' => 5, 'coins_max' => 10], + 'engagement' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3], + 'repo_star' => ['tier' => 'low', 'coins_min' => 2, 'coins_max' => 2], + 'message' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 2], + 'voice' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3], + ], + + 'auto_approve_tiers' => ['low', 'medium'], + + 'engagement_formula' => [ + 'reactions_multiplier' => 0.5, + 'reactions_cap' => 25, + 'bookmarks_multiplier' => 1.0, + 'bookmarks_cap' => 15, + 'comments_multiplier' => 2.0, + 'comments_cap' => 30, + ], + + 'xp_multiplier' => 1, +]; diff --git a/app-modules/activity/database/factories/InteractionFactory.php b/app-modules/activity/database/factories/InteractionFactory.php new file mode 100644 index 00000000..b9f80b5e --- /dev/null +++ b/app-modules/activity/database/factories/InteractionFactory.php @@ -0,0 +1,69 @@ + + */ +final class InteractionFactory extends Factory +{ + protected $model = Interaction::class; + + public function definition(): array + { + return [ + 'character_id' => Character::factory(), + 'tenant_id' => Tenant::factory(), + 'type' => ActivityType::Article, + 'provider' => IdentityProvider::DevTo, + 'value_tier' => ValueTier::High, + 'coins_min' => 100, + 'coins_max' => 300, + 'status' => ActivityStatus::Pending, + 'occurred_at' => now(), + ]; + } + + public function autoApproved(): self + { + return $this->state([ + 'status' => ActivityStatus::AutoApproved, + 'type' => ActivityType::Engagement, + 'value_tier' => ValueTier::Low, + 'coins_min' => 1, + 'coins_max' => 3, + ]); + } + + public function approved(): self + { + return $this->state([ + 'status' => ActivityStatus::Approved, + 'reviewed_at' => now(), + ]); + } + + public function withEngagement(int $reactions = 0, int $comments = 0, int $bookmarks = 0): self + { + return $this->state([ + 'metadata' => [ + 'engagement_snapshot' => [ + 'reactions' => $reactions, + 'comments' => $comments, + 'bookmarks' => $bookmarks, + ], + ], + ]); + } +} diff --git a/app-modules/activity/database/factories/MessageFactory.php b/app-modules/activity/database/factories/MessageFactory.php index 87fda66a..d8ea1e20 100644 --- a/app-modules/activity/database/factories/MessageFactory.php +++ b/app-modules/activity/database/factories/MessageFactory.php @@ -4,7 +4,7 @@ namespace He4rt\Activity\Database\Factories; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; diff --git a/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php b/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php new file mode 100644 index 00000000..35861eb8 --- /dev/null +++ b/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php @@ -0,0 +1,43 @@ +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'); + } +}; diff --git a/app-modules/activity/routes/message-routes.php b/app-modules/activity/routes/message-routes.php index 4f6ae559..5302bde1 100644 --- a/app-modules/activity/routes/message-routes.php +++ b/app-modules/activity/routes/message-routes.php @@ -4,7 +4,7 @@ use App\Http\Middleware\BotAuthentication; use App\Http\Middleware\VerifyIfHasTenantProviderMiddleware; -use He4rt\Activity\Http\Controllers\MessagesController; +use He4rt\Activity\Message\Http\Controllers\MessagesController; use Illuminate\Support\Facades\Route; Route::prefix('api')->middleware(['api', BotAuthentication::class, VerifyIfHasTenantProviderMiddleware::class])->group(function (): void { diff --git a/app-modules/activity/src/Actions/NewMessage.php b/app-modules/activity/src/Message/Actions/NewMessage.php similarity index 95% rename from app-modules/activity/src/Actions/NewMessage.php rename to app-modules/activity/src/Message/Actions/NewMessage.php index ee368ea7..37bf09e7 100644 --- a/app-modules/activity/src/Actions/NewMessage.php +++ b/app-modules/activity/src/Message/Actions/NewMessage.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace He4rt\Activity\Actions; +namespace He4rt\Activity\Message\Actions; -use He4rt\Activity\DTOs\NewMessageDTO; +use He4rt\Activity\Message\DTOs\NewMessageDTO; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\User\Actions\ResolveUserContext; diff --git a/app-modules/activity/src/Actions/PersistMessage.php b/app-modules/activity/src/Message/Actions/PersistMessage.php similarity index 82% rename from app-modules/activity/src/Actions/PersistMessage.php rename to app-modules/activity/src/Message/Actions/PersistMessage.php index 2cd4d4d9..0e1f1175 100644 --- a/app-modules/activity/src/Actions/PersistMessage.php +++ b/app-modules/activity/src/Message/Actions/PersistMessage.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace He4rt\Activity\Actions; +namespace He4rt\Activity\Message\Actions; -use He4rt\Activity\DTOs\NewMessageDTO; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\DTOs\NewMessageDTO; +use He4rt\Activity\Message\Models\Message; class PersistMessage { diff --git a/app-modules/activity/src/DTOs/NewMessageDTO.php b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php similarity index 96% rename from app-modules/activity/src/DTOs/NewMessageDTO.php rename to app-modules/activity/src/Message/DTOs/NewMessageDTO.php index 3c84496e..83fa0e25 100644 --- a/app-modules/activity/src/DTOs/NewMessageDTO.php +++ b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\DTOs; +namespace He4rt\Activity\Message\DTOs; use DateTimeImmutable; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; diff --git a/app-modules/activity/src/Http/Controllers/MessagesController.php b/app-modules/activity/src/Message/Http/Controllers/MessagesController.php similarity index 68% rename from app-modules/activity/src/Http/Controllers/MessagesController.php rename to app-modules/activity/src/Message/Http/Controllers/MessagesController.php index 5951318e..61e9876c 100644 --- a/app-modules/activity/src/Http/Controllers/MessagesController.php +++ b/app-modules/activity/src/Message/Http/Controllers/MessagesController.php @@ -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 diff --git a/app-modules/activity/src/Http/Requests/CreateMessageRequest.php b/app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php similarity index 86% rename from app-modules/activity/src/Http/Requests/CreateMessageRequest.php rename to app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php index 95b13c06..d80f70be 100644 --- a/app-modules/activity/src/Http/Requests/CreateMessageRequest.php +++ b/app-modules/activity/src/Message/Http/Requests/CreateMessageRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Http\Requests; +namespace He4rt\Activity\Message\Http\Requests; use Illuminate\Foundation\Http\FormRequest; @@ -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'], diff --git a/app-modules/activity/src/Models/Message.php b/app-modules/activity/src/Message/Models/Message.php similarity index 96% rename from app-modules/activity/src/Models/Message.php rename to app-modules/activity/src/Message/Models/Message.php index 8f785682..4cf34650 100644 --- a/app-modules/activity/src/Models/Message.php +++ b/app-modules/activity/src/Message/Models/Message.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Models; +namespace He4rt\Activity\Message\Models; use He4rt\Activity\Database\Factories\MessageFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; diff --git a/app-modules/activity/src/Providers/ActivityServiceProvider.php b/app-modules/activity/src/Providers/ActivityServiceProvider.php index 4616aee0..ef21e88b 100644 --- a/app-modules/activity/src/Providers/ActivityServiceProvider.php +++ b/app-modules/activity/src/Providers/ActivityServiceProvider.php @@ -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 { diff --git a/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php b/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php new file mode 100644 index 00000000..75b4d67b --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php @@ -0,0 +1,63 @@ +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(); + }); + } +} diff --git a/app-modules/activity/src/Tracking/Actions/CalculateReward.php b/app-modules/activity/src/Tracking/Actions/CalculateReward.php new file mode 100644 index 00000000..8feb443c --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/CalculateReward.php @@ -0,0 +1,60 @@ +metadata ?? []; + $engagementSnapshot = $metadata['engagement_snapshot'] ?? null; + + if ($engagementSnapshot !== null) { + $base = $peerReviewBase ?? (int) (($interaction->coins_min + $interaction->coins_max) / 2); + + $reactionsBonus = min( + ($engagementSnapshot['reactions'] ?? 0) * $engagementFormula['reactions_multiplier'], + $engagementFormula['reactions_cap'] + ); + + $bookmarksBonus = min( + ($engagementSnapshot['bookmarks'] ?? 0) * $engagementFormula['bookmarks_multiplier'], + $engagementFormula['bookmarks_cap'] + ); + + $commentsBonus = min( + ($engagementSnapshot['comments'] ?? 0) * $engagementFormula['comments_multiplier'], + $engagementFormula['comments_cap'] + ); + + $engagementBonus = (int) ($reactionsBonus + $bookmarksBonus + $commentsBonus); + $coinsAwarded = min($base + $engagementBonus, $interaction->coins_max); + } else { + $coinsAwarded = $peerReviewBase !== null + ? min($peerReviewBase, $interaction->coins_max) + : $interaction->coins_min; + } + + $xpAwarded = (int) ($coinsAwarded * $xpMultiplier); + + $interaction->update([ + 'coins_awarded' => $coinsAwarded, + 'xp_awarded' => $xpAwarded, + ]); + + return [ + 'coins_awarded' => $coinsAwarded, + 'xp_awarded' => $xpAwarded, + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php b/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php new file mode 100644 index 00000000..b307b0d0 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php @@ -0,0 +1,34 @@ +value); + + $tier = ValueTier::from($classification['tier']); + $autoApproveTiers = config('activity-tracking.auto_approve_tiers', []); + + $status = in_array($tier->value, $autoApproveTiers, true) + ? ActivityStatus::AutoApproved + : ActivityStatus::Pending; + + return [ + 'tier' => $tier, + 'coins_min' => $classification['coins_min'], + 'coins_max' => $classification['coins_max'], + 'status' => $status, + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Actions/RejectInteraction.php b/app-modules/activity/src/Tracking/Actions/RejectInteraction.php new file mode 100644 index 00000000..1c6063ee --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/RejectInteraction.php @@ -0,0 +1,25 @@ +status !== ActivityStatus::Pending) { + return $interaction; + } + + $interaction->update([ + 'status' => ActivityStatus::Rejected, + 'reviewed_at' => now(), + ]); + + return $interaction->fresh(); + } +} diff --git a/app-modules/activity/src/Tracking/Actions/TrackActivity.php b/app-modules/activity/src/Tracking/Actions/TrackActivity.php new file mode 100644 index 00000000..52b39dbe --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/TrackActivity.php @@ -0,0 +1,73 @@ +externalRef !== null) { + $exists = Interaction::query() + ->where('external_ref', $dto->externalRef) + ->exists(); + + if ($exists) { + return null; + } + } + + $classification = $this->classifyActivity->handle($dto->type); + + $interaction = Interaction::query()->create([ + 'character_id' => $dto->characterId, + 'tenant_id' => $dto->tenantId, + 'type' => $dto->type, + 'provider' => $dto->provider, + 'value_tier' => $classification['tier'], + 'coins_min' => $classification['coins_min'], + 'coins_max' => $classification['coins_max'], + 'status' => $classification['status'], + 'source_type' => $dto->sourceType, + 'source_id' => $dto->sourceId, + 'external_ref' => $dto->externalRef, + 'metadata' => $dto->metadata, + 'occurred_at' => $dto->occurredAt, + ]); + + if ($classification['status'] === ActivityStatus::AutoApproved) { + $reward = $this->calculateReward->handle($interaction); + + $character = Character::query()->findOrFail($dto->characterId); + $wallet = $character->getOrCreateWallet(); + + resolve(Credit::class)->handle(new CreditDTO( + walletId: $wallet->id, + amount: $reward['coins_awarded'], + referenceType: Interaction::class, + referenceId: $interaction->id, + description: 'Reward: '.$dto->type->value, + )); + + $character->increment('experience', $reward['xp_awarded']); + } + + event(new InteractionTracked($interaction)); + + return $interaction; + } +} diff --git a/app-modules/activity/src/Tracking/Concerns/HasInteractions.php b/app-modules/activity/src/Tracking/Concerns/HasInteractions.php new file mode 100644 index 00000000..de9028ff --- /dev/null +++ b/app-modules/activity/src/Tracking/Concerns/HasInteractions.php @@ -0,0 +1,19 @@ + + */ + public function interactions(): HasMany + { + return $this->hasMany(Interaction::class); + } +} diff --git a/app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php b/app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php new file mode 100644 index 00000000..dd2e258d --- /dev/null +++ b/app-modules/activity/src/Tracking/Contracts/ActivitySourceContract.php @@ -0,0 +1,15 @@ + + */ + public function fetchActivities(): array; +} diff --git a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php new file mode 100644 index 00000000..7789e8c9 --- /dev/null +++ b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php @@ -0,0 +1,25 @@ +|null */ + public ?array $metadata = null, + ) {} +} diff --git a/app-modules/activity/src/Tracking/Enums/ActivityStatus.php b/app-modules/activity/src/Tracking/Enums/ActivityStatus.php new file mode 100644 index 00000000..41f4a45a --- /dev/null +++ b/app-modules/activity/src/Tracking/Enums/ActivityStatus.php @@ -0,0 +1,40 @@ + 'Pending', + self::AutoApproved => 'Auto Approved', + self::InReview => 'In Review', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } + + public function getColor(): array + { + return match ($this) { + self::Pending => Color::Yellow, + self::AutoApproved => Color::Blue, + self::InReview => Color::Orange, + self::Approved => Color::Green, + self::Rejected => Color::Red, + }; + } +} diff --git a/app-modules/activity/src/Tracking/Enums/ActivityType.php b/app-modules/activity/src/Tracking/Enums/ActivityType.php new file mode 100644 index 00000000..0ad73155 --- /dev/null +++ b/app-modules/activity/src/Tracking/Enums/ActivityType.php @@ -0,0 +1,22 @@ + 'High', + self::Medium => 'Medium', + self::Low => 'Low', + }; + } + + public function getColor(): array + { + return match ($this) { + self::High => Color::Red, + self::Medium => Color::Yellow, + self::Low => Color::Gray, + }; + } +} diff --git a/app-modules/activity/src/Tracking/Events/InteractionApproved.php b/app-modules/activity/src/Tracking/Events/InteractionApproved.php new file mode 100644 index 00000000..0e4d11d7 --- /dev/null +++ b/app-modules/activity/src/Tracking/Events/InteractionApproved.php @@ -0,0 +1,17 @@ +|null $metadata + * @property Carbon $occurred_at + * @property Carbon|null $reviewed_at + */ +final class Interaction extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'interactions'; + + protected $fillable = [ + 'id', + 'character_id', + 'tenant_id', + 'type', + 'provider', + 'value_tier', + 'coins_min', + 'coins_max', + 'coins_awarded', + 'xp_awarded', + 'status', + 'source_type', + 'source_id', + 'external_ref', + 'metadata', + 'occurred_at', + 'reviewed_at', + ]; + + /** + * @return BelongsTo + */ + public function character(): BelongsTo + { + return $this->belongsTo(Character::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return MorphTo + */ + public function source(): MorphTo + { + return $this->morphTo(); + } + + protected static function newFactory(): InteractionFactory + { + return InteractionFactory::new(); + } + + protected function casts(): array + { + return [ + 'type' => ActivityType::class, + 'provider' => IdentityProvider::class, + 'value_tier' => ValueTier::class, + 'status' => ActivityStatus::class, + 'coins_min' => 'integer', + 'coins_max' => 'integer', + 'coins_awarded' => 'integer', + 'xp_awarded' => 'integer', + 'metadata' => 'array', + 'occurred_at' => 'datetime', + 'reviewed_at' => 'datetime', + ]; + } +} diff --git a/app-modules/activity/src/Actions/NewVoiceMessage.php b/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php similarity index 91% rename from app-modules/activity/src/Actions/NewVoiceMessage.php rename to app-modules/activity/src/Voice/Actions/NewVoiceMessage.php index 478c2fa1..a437398d 100644 --- a/app-modules/activity/src/Actions/NewVoiceMessage.php +++ b/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace He4rt\Activity\Actions; +namespace He4rt\Activity\Voice\Actions; -use He4rt\Activity\DTOs\NewVoiceMessageDTO; -use He4rt\Activity\Models\Voice; +use He4rt\Activity\Voice\DTOs\NewVoiceMessageDTO; +use He4rt\Activity\Voice\Models\Voice; use He4rt\Gamification\Character\Actions\IncrementExperience; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Actions\FindExternalIdentity; diff --git a/app-modules/activity/src/DTOs/NewVoiceMessageDTO.php b/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php similarity index 95% rename from app-modules/activity/src/DTOs/NewVoiceMessageDTO.php rename to app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php index 136310f2..a5230eef 100644 --- a/app-modules/activity/src/DTOs/NewVoiceMessageDTO.php +++ b/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\DTOs; +namespace He4rt\Activity\Voice\DTOs; use He4rt\Gamification\Character\Enums\VoiceStatesEnum; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; diff --git a/app-modules/activity/src/Http/Requests/CreateVoiceMessageRequest.php b/app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php similarity index 93% rename from app-modules/activity/src/Http/Requests/CreateVoiceMessageRequest.php rename to app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php index 3c75d17d..18003f25 100644 --- a/app-modules/activity/src/Http/Requests/CreateVoiceMessageRequest.php +++ b/app-modules/activity/src/Voice/Http/Requests/CreateVoiceMessageRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Http\Requests; +namespace He4rt\Activity\Voice\Http\Requests; use Illuminate\Foundation\Http\FormRequest; diff --git a/app-modules/activity/src/Models/Voice.php b/app-modules/activity/src/Voice/Models/Voice.php similarity index 94% rename from app-modules/activity/src/Models/Voice.php rename to app-modules/activity/src/Voice/Models/Voice.php index aad2ec90..bbfe23f7 100644 --- a/app-modules/activity/src/Models/Voice.php +++ b/app-modules/activity/src/Voice/Models/Voice.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Activity\Models; +namespace He4rt\Activity\Voice\Models; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use Illuminate\Database\Eloquent\Model; diff --git a/app-modules/activity/tests/Feature/NewVoiceMessageTest.php b/app-modules/activity/tests/Feature/NewVoiceMessageTest.php index 30d27f51..51a32bd0 100644 --- a/app-modules/activity/tests/Feature/NewVoiceMessageTest.php +++ b/app-modules/activity/tests/Feature/NewVoiceMessageTest.php @@ -24,7 +24,7 @@ $user = User::factory() ->has(Character::factory(['tenant_id' => $tenant->getKey(), 'experience' => 1]), 'character') - ->has(ExternalIdentity::factory(['tenant_id' => $tenant->getKey()]), 'providers') + ->has(ExternalIdentity::factory(['tenant_id' => $tenant->getKey(), 'provider' => IdentityProvider::Discord]), 'providers') ->create(); $provider = $user->providers[0]; diff --git a/app-modules/activity/tests/Unit/Actions/NewMessageTest.php b/app-modules/activity/tests/Unit/Actions/NewMessageTest.php index 40462a2f..87fd2c68 100644 --- a/app-modules/activity/tests/Unit/Actions/NewMessageTest.php +++ b/app-modules/activity/tests/Unit/Actions/NewMessageTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use He4rt\Activity\Actions\NewMessage; -use He4rt\Activity\Actions\PersistMessage; -use He4rt\Activity\DTOs\NewMessageDTO; +use He4rt\Activity\Message\Actions\NewMessage; +use He4rt\Activity\Message\Actions\PersistMessage; +use He4rt\Activity\Message\DTOs\NewMessageDTO; use He4rt\Community\Meeting\Actions\AttendMeeting; use He4rt\Identity\ExternalIdentity\Actions\FindExternalIdentity; use He4rt\Identity\ExternalIdentity\Actions\LinkExternalIdentity as NewAccountByProvider; diff --git a/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php new file mode 100644 index 00000000..e13de21f --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php @@ -0,0 +1,44 @@ +create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(['experience' => 500]); + + $interaction = Interaction::factory() + ->withEngagement(reactions: 42, comments: 12, bookmarks: 8) + ->recycle($character) + ->recycle($tenant) + ->create([ + 'coins_min' => 100, + 'coins_max' => 300, + 'status' => ActivityStatus::Pending, + ]); + + $result = resolve(ApproveInteraction::class)->handle($interaction, peerReviewBase: 200); + + expect($result->status)->toBe(ActivityStatus::Approved) + ->and($result->reviewed_at)->not->toBeNull() + ->and($result->coins_awarded)->toBe(253); + + $wallet = $character->fresh()->wallets()->first(); + expect($wallet->balance)->toBe(253); + + Event::assertDispatched(InteractionApproved::class); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php b/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php new file mode 100644 index 00000000..fe760c1d --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php @@ -0,0 +1,53 @@ +withEngagement(reactions: 42, comments: 12, bookmarks: 8) + ->create([ + 'coins_min' => 100, + 'coins_max' => 300, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction, peerReviewBase: 200); + + // reactions bonus: min(42 * 0.5, 25) = 21 + // bookmarks bonus: min(8 * 1.0, 15) = 8 + // comments bonus: min(12 * 2.0, 30) = 24 + // total engagement: 53 + // coins_awarded: min(200 + 53, 300) = 253 + expect($result['coins_awarded'])->toBe(253) + ->and($result['xp_awarded'])->toBe(253); +}); + +test('caps engagement bonus at coins max', function (): void { + $interaction = Interaction::factory() + ->withEngagement(reactions: 100, comments: 100, bookmarks: 100) + ->create([ + 'coins_min' => 100, + 'coins_max' => 200, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction, peerReviewBase: 180); + + expect($result['coins_awarded'])->toBe(200); +}); + +test('uses coins_min when no engagement and auto approved', function (): void { + $interaction = Interaction::factory()->create([ + 'coins_min' => 5, + 'coins_max' => 10, + 'metadata' => null, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction); + + expect($result['coins_awarded'])->toBe(5); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php b/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php new file mode 100644 index 00000000..28c986fa --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php @@ -0,0 +1,35 @@ +handle(ActivityType::Article); + + expect($result['tier'])->toBe(ValueTier::High) + ->and($result['coins_min'])->toBe(100) + ->and($result['coins_max'])->toBe(300) + ->and($result['status'])->toBe(ActivityStatus::Pending); +}); + +test('classifies medium tier activity as auto approved', function (): void { + $result = resolve(ClassifyActivity::class)->handle(ActivityType::Referral); + + expect($result['tier'])->toBe(ValueTier::Medium) + ->and($result['coins_min'])->toBe(20) + ->and($result['coins_max'])->toBe(30) + ->and($result['status'])->toBe(ActivityStatus::AutoApproved); +}); + +test('classifies low tier activity as auto approved', function (): void { + $result = resolve(ClassifyActivity::class)->handle(ActivityType::Engagement); + + expect($result['tier'])->toBe(ValueTier::Low) + ->and($result['coins_min'])->toBe(1) + ->and($result['coins_max'])->toBe(3) + ->and($result['status'])->toBe(ActivityStatus::AutoApproved); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php new file mode 100644 index 00000000..a2ffd439 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php @@ -0,0 +1,21 @@ +create([ + 'status' => ActivityStatus::Pending, + ]); + + $result = resolve(RejectInteraction::class)->handle($interaction); + + expect($result->status)->toBe(ActivityStatus::Rejected) + ->and($result->reviewed_at)->not->toBeNull(); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php new file mode 100644 index 00000000..181f8f6b --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php @@ -0,0 +1,97 @@ +create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + tenantId: $tenant->id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + externalRef: 'devto:article:123', + ); + + $interaction = resolve(TrackActivity::class)->handle($dto); + + expect($interaction)->not->toBeNull() + ->and($interaction->status)->toBe(ActivityStatus::Pending) + ->and($interaction->value_tier)->toBe(ValueTier::High) + ->and($interaction->coins_min)->toBe(100) + ->and($interaction->coins_max)->toBe(300) + ->and($interaction->coins_awarded)->toBeNull(); + + Event::assertDispatched(InteractionTracked::class); +}); + +test('tracks low tier activity as auto approved and credits economy', function (): void { + Event::fake([InteractionTracked::class]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + tenantId: $tenant->id, + type: ActivityType::Engagement, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + ); + + $interaction = resolve(TrackActivity::class)->handle($dto); + + expect($interaction)->not->toBeNull() + ->and($interaction->status)->toBe(ActivityStatus::AutoApproved) + ->and($interaction->value_tier)->toBe(ValueTier::Low) + ->and($interaction->coins_awarded)->toBe(1); + + $wallet = $character->fresh()->wallets()->first(); + expect($wallet)->not->toBeNull() + ->and($wallet->balance)->toBe(1); + + Event::assertDispatched(InteractionTracked::class); +}); + +test('deduplicates by external ref', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + tenantId: $tenant->id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + externalRef: 'devto:article:456', + ); + + $first = resolve(TrackActivity::class)->handle($dto); + $second = resolve(TrackActivity::class)->handle($dto); + + expect($first)->not->toBeNull() + ->and($second)->toBeNull(); +}); diff --git a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php index 40f3d9bd..7bf0d212 100644 --- a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php +++ b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php @@ -7,8 +7,8 @@ use Discord\Discord; use Discord\Parts\Channel\Message; use Discord\WebSockets\Event as Events; -use He4rt\Activity\Actions\NewMessage; -use He4rt\Activity\DTOs\NewMessageDTO; +use He4rt\Activity\Message\Actions\NewMessage; +use He4rt\Activity\Message\DTOs\NewMessageDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; diff --git a/app-modules/gamification/src/Character/Models/Character.php b/app-modules/gamification/src/Character/Models/Character.php index 38a01ad3..aa74d44b 100644 --- a/app-modules/gamification/src/Character/Models/Character.php +++ b/app-modules/gamification/src/Character/Models/Character.php @@ -5,6 +5,7 @@ namespace He4rt\Gamification\Character\Models; use Carbon\Carbon; +use He4rt\Activity\Tracking\Concerns\HasInteractions; use He4rt\Economy\Concerns\HasWallet; use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Database\Factories\CharacterFactory; @@ -34,6 +35,7 @@ final class Character extends Model { /** @use HasFactory */ use HasFactory; + use HasInteractions; use HasUuids; use HasWallet; diff --git a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php index 837bc19f..09189070 100644 --- a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php +++ b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php @@ -11,6 +11,7 @@ use Filament\Support\Contracts\HasIcon; use Filament\Support\Contracts\HasLabel; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; +use He4rt\IntegrationDevTo\OAuth\DevToOAuthClient; use He4rt\IntegrationDiscord\OAuth\DiscordOAuthClient; use He4rt\IntegrationTwitch\OAuth\Contracts\TwitchOAuthService; @@ -18,12 +19,14 @@ enum IdentityProvider: string implements HasColor, HasDescription, HasIcon, HasL { case Discord = 'discord'; case Twitch = 'twitch'; + case DevTo = 'devto'; public function getClient(): OAuthClientContract { return match ($this) { self::Twitch => resolve(TwitchOAuthService::class), self::Discord => resolve(DiscordOAuthClient::class), + self::DevTo => resolve(DevToOAuthClient::class), }; } @@ -32,6 +35,7 @@ public function getColor(): array return match ($this) { self::Discord => Color::Blue, self::Twitch => Color::Purple, + self::DevTo => Color::Gray, }; } @@ -40,6 +44,7 @@ public function getIcon(): string return match ($this) { self::Discord => 'fab-discord', self::Twitch => 'fab-twitch', + self::DevTo => 'fab-dev', }; } @@ -53,6 +58,7 @@ public function getDescription(): string return match ($this) { self::Discord => 'Conecte sua conta do Discord para gameficações e eventos.', self::Twitch => 'Conecte sua conta do Twitch para gameficações e eventos.', + self::DevTo => 'Conecte sua conta do Dev.to para rastrear artigos e contribuições.', }; } @@ -64,6 +70,7 @@ public function getScopes(): array $scopes = match ($this) { self::Discord => config('services.discord.scopes'), self::Twitch => config('services.twitch.scopes'), + self::DevTo => config('services.devto.scopes'), }; return explode(' ', $scopes); @@ -87,7 +94,7 @@ public function getRedirectUri(?string $tenant = null): string public function getType(): IdentityType { return match ($this) { - self::Discord, self::Twitch => IdentityType::External, + self::Discord, self::Twitch, self::DevTo => IdentityType::External, }; } } diff --git a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php index 91591568..93ea7230 100644 --- a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php @@ -4,7 +4,7 @@ namespace He4rt\Identity\ExternalIdentity\Models; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Identity\Database\Factories\ExternalIdentityFactory; use He4rt\Identity\ExternalIdentity\Casts\AsCredentials; use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager; diff --git a/app-modules/identity/src/Tenant/Models/Tenant.php b/app-modules/identity/src/Tenant/Models/Tenant.php index a90d2b3d..d4c291ae 100644 --- a/app-modules/identity/src/Tenant/Models/Tenant.php +++ b/app-modules/identity/src/Tenant/Models/Tenant.php @@ -5,7 +5,7 @@ namespace He4rt\Identity\Tenant\Models; use Carbon\Carbon; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Events\Models\EventModel; use He4rt\Gamification\Character\Models\PastSeason; use He4rt\Gamification\Season\Models\Season; diff --git a/app-modules/identity/tests/Feature/FindProfileTest.php b/app-modules/identity/tests/Feature/FindProfileTest.php index 00ae4a2e..ff79ca62 100644 --- a/app-modules/identity/tests/Feature/FindProfileTest.php +++ b/app-modules/identity/tests/Feature/FindProfileTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Character\Models\Character; use He4rt\Gamification\Character\Models\PastSeason; diff --git a/app-modules/identity/tests/Feature/UpdateProfileTest.php b/app-modules/identity/tests/Feature/UpdateProfileTest.php index 4404b65a..59d8fee4 100644 --- a/app-modules/identity/tests/Feature/UpdateProfileTest.php +++ b/app-modules/identity/tests/Feature/UpdateProfileTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Gamification\Character\Models\Character; use He4rt\Gamification\Character\Models\PastSeason; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; diff --git a/app-modules/integration-devto/composer.json b/app-modules/integration-devto/composer.json new file mode 100644 index 00000000..cabc606f --- /dev/null +++ b/app-modules/integration-devto/composer.json @@ -0,0 +1,24 @@ +{ + "name": "he4rt/integration-devto", + "description": "", + "type": "library", + "version": "1.0", + "license": "proprietary", + "require": {}, + "autoload": { + "psr-4": { + "He4rt\\IntegrationDevTo\\": "src/", + "He4rt\\IntegrationDevTo\\Tests\\": "tests/", + "He4rt\\IntegrationDevTo\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationDevTo\\Database\\Seeders\\": "database/seeders/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationDevTo\\Providers\\IntegrationDevToServiceProvider" + ] + } + } +} diff --git a/app-modules/integration-devto/config/integration-devto.php b/app-modules/integration-devto/config/integration-devto.php new file mode 100644 index 00000000..b694d339 --- /dev/null +++ b/app-modules/integration-devto/config/integration-devto.php @@ -0,0 +1,9 @@ + env('DEVTO_ORG_SLUG', 'he4rt'), + 'api_base_url' => env('DEVTO_API_URL', 'https://dev.to/api'), + 'polling_interval_minutes' => env('DEVTO_POLLING_INTERVAL', 30), +]; diff --git a/app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php b/app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php new file mode 100644 index 00000000..96a0e485 --- /dev/null +++ b/app-modules/integration-devto/src/OAuth/DevToOAuthAccessDTO.php @@ -0,0 +1,19 @@ + config('services.devto.client_id'), + 'response_type' => 'code', + 'redirect_uri' => config('services.devto.redirect_uri'), + 'scope' => config('services.devto.scopes'), + 'state' => (string) $state, + ]); + } + + public function auth(string $code): OAuthAccessDTO + { + $response = Http::asForm()->post('https://dev.to/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => config('services.devto.redirect_uri'), + 'client_id' => config('services.devto.client_id'), + 'client_secret' => config('services.devto.client_secret'), + ]); + + return DevToOAuthAccessDTO::make($response->json()); + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO + { + $response = Http::withToken($credentials->accessToken) + ->get('https://dev.to/api/users/me'); + + return DevToOAuthUser::make($credentials, $response->json()); + } +} diff --git a/app-modules/integration-devto/src/OAuth/DevToOAuthUser.php b/app-modules/integration-devto/src/OAuth/DevToOAuthUser.php new file mode 100644 index 00000000..7802a711 --- /dev/null +++ b/app-modules/integration-devto/src/OAuth/DevToOAuthUser.php @@ -0,0 +1,25 @@ + $orgSlug, + 'per_page' => $perPage, + 'page' => $page, + ]); + + return $response->json() ?? []; + } + + public function getArticle(int $articleId): array + { + $baseUrl = config('integration-devto.api_base_url'); + + $response = Http::get(sprintf('%s/articles/%d', $baseUrl, $articleId)); + + return $response->json() ?? []; + } +} diff --git a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php new file mode 100644 index 00000000..eeda140c --- /dev/null +++ b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php @@ -0,0 +1,143 @@ +info('Syncing articles from DevTo org: '.$orgSlug); + + do { + $articles = $this->apiClient->getArticlesByOrg($orgSlug, $page); + + foreach ($articles as $article) { + $result = $this->processArticle($article); + + match ($result) { + 'created' => $totalCreated++, + 'updated' => $totalUpdated++, + 'skipped' => $totalSkipped++, + }; + } + + $page++; + } while (count($articles) === 30); + + $this->info(sprintf('Sync complete: %d created, %d updated, %d skipped', $totalCreated, $totalUpdated, $totalSkipped)); + + return self::SUCCESS; + } + + private function processArticle(array $article): string + { + $devToUsername = $article['user']['username'] ?? null; + + if ($devToUsername === null) { + return 'skipped'; + } + + $externalIdentity = ExternalIdentity::query() + ->where('provider', IdentityProvider::DevTo) + ->where('metadata->username', $devToUsername) + ->where('model_type', User::class) + ->first(); + + if ($externalIdentity === null) { + Log::info('DevTo sync: author not linked', [ + 'devto_username' => $devToUsername, + 'article_id' => $article['id'], + 'title' => $article['title'], + ]); + + return 'skipped'; + } + + $externalRef = 'devto:article:'.$article['id']; + + $existingInteraction = Interaction::query() + ->where('external_ref', $externalRef) + ->first(); + + if ($existingInteraction !== null) { + $articleDetails = $this->apiClient->getArticle($article['id']); + + $existingInteraction->update([ + 'metadata' => array_merge($existingInteraction->metadata ?? [], [ + 'engagement_snapshot' => [ + 'reactions' => $articleDetails['public_reactions_count'] ?? $article['public_reactions_count'] ?? 0, + 'comments' => $articleDetails['comments_count'] ?? $article['comments_count'] ?? 0, + 'bookmarks' => $articleDetails['reading_list_count'] ?? 0, + ], + ]), + ]); + + return 'updated'; + } + + $character = $externalIdentity->user?->character; + + if ($character === null) { + Log::info('DevTo sync: user has no character', [ + 'devto_username' => $devToUsername, + 'user_id' => $externalIdentity->model_id, + ]); + + return 'skipped'; + } + + $articleDetails = $this->apiClient->getArticle($article['id']); + + $this->trackActivity->handle(new TrackActivityDTO( + characterId: $character->id, + tenantId: (int) $externalIdentity->tenant_id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: new DateTimeImmutable($article['published_at'] ?? $article['created_at']), + externalRef: $externalRef, + metadata: [ + 'devto_article_id' => $article['id'], + 'title' => $article['title'], + 'url' => $article['url'], + 'tags' => $article['tag_list'] ?? [], + 'engagement_snapshot' => [ + 'reactions' => $articleDetails['public_reactions_count'] ?? $article['public_reactions_count'] ?? 0, + 'comments' => $articleDetails['comments_count'] ?? $article['comments_count'] ?? 0, + 'bookmarks' => $articleDetails['reading_list_count'] ?? 0, + ], + ], + )); + + return 'created'; + } +} diff --git a/app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php b/app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php new file mode 100644 index 00000000..5629c9f5 --- /dev/null +++ b/app-modules/integration-devto/src/Providers/IntegrationDevToServiceProvider.php @@ -0,0 +1,31 @@ +mergeConfigFrom(__DIR__.'/../../config/integration-devto.php', 'integration-devto'); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + SyncDevToArticles::class, + ]); + + $this->app->booted(function (): void { + $schedule = $this->app->make(Schedule::class); + $schedule->command('devto:sync-articles')->everyThirtyMinutes(); + }); + } + } +} diff --git a/app-modules/integration-devto/tests/Feature/DevToOAuthTest.php b/app-modules/integration-devto/tests/Feature/DevToOAuthTest.php new file mode 100644 index 00000000..1098ecd2 --- /dev/null +++ b/app-modules/integration-devto/tests/Feature/DevToOAuthTest.php @@ -0,0 +1,69 @@ + 'test-client-id', + 'services.devto.redirect_uri' => 'https://example.com/callback', + 'services.devto.scopes' => 'public', + ]); + + $client = new DevToOAuthClient(); + $url = $client->redirectUrl(); + + expect($url)->toContain('https://dev.to/oauth/authorize') + ->and($url)->toContain('client_id=test-client-id') + ->and($url)->toContain('response_type=code') + ->and($url)->toContain('scope=public'); +}); + +test('exchanges code for access token', function (): void { + Http::fake([ + 'dev.to/oauth/token' => Http::response([ + 'access_token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 3600, + ]), + ]); + + $client = new DevToOAuthClient(); + $dto = $client->auth('test-code'); + + expect($dto)->toBeInstanceOf(DevToOAuthAccessDTO::class) + ->and($dto->accessToken)->toBe('test-access-token') + ->and($dto->refreshToken)->toBe('test-refresh-token'); +}); + +test('fetches authenticated user info', function (): void { + Http::fake([ + 'dev.to/api/users/me' => Http::response([ + 'id' => 12345, + 'username' => 'testuser', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'profile_image' => 'https://dev.to/avatar.png', + ]), + ]); + + $accessDTO = DevToOAuthAccessDTO::make([ + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_in' => 3600, + ]); + + $client = new DevToOAuthClient(); + $user = $client->getAuthenticatedUser($accessDTO); + + expect($user)->toBeInstanceOf(DevToOAuthUser::class) + ->and($user->providerId)->toBe('12345') + ->and($user->provider)->toBe(IdentityProvider::DevTo) + ->and($user->username)->toBe('testuser') + ->and($user->name)->toBe('Test User'); +}); diff --git a/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php b/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php new file mode 100644 index 00000000..980dd5dc --- /dev/null +++ b/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php @@ -0,0 +1,127 @@ + Http::response([ + [ + 'id' => 101, + 'title' => 'PHP is awesome', + 'url' => 'https://dev.to/linked_user/php-is-awesome', + 'published_at' => '2026-03-15T10:00:00Z', + 'created_at' => '2026-03-15T09:00:00Z', + 'tag_list' => ['php', 'laravel'], + 'public_reactions_count' => 10, + 'comments_count' => 3, + 'user' => ['username' => 'linked_user'], + ], + [ + 'id' => 102, + 'title' => 'Unlinked article', + 'url' => 'https://dev.to/unknown_user/unlinked', + 'published_at' => '2026-03-16T10:00:00Z', + 'created_at' => '2026-03-16T09:00:00Z', + 'tag_list' => ['go'], + 'public_reactions_count' => 5, + 'comments_count' => 1, + 'user' => ['username' => 'unknown_user'], + ], + ]), + '*/articles?*page=2*' => Http::response([]), + '*/articles/101' => Http::response([ + 'id' => 101, + 'public_reactions_count' => 10, + 'comments_count' => 3, + 'reading_list_count' => 2, + ]), + ]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'provider' => IdentityProvider::DevTo, + 'metadata' => ['email' => 'linked@example.com', 'username' => 'linked_user'], + ]); + + $this->artisan('devto:sync-articles') + ->assertSuccessful(); + + expect(Interaction::query()->count())->toBe(1); + + $interaction = Interaction::query()->first(); + expect($interaction->type)->toBe(ActivityType::Article) + ->and($interaction->external_ref)->toBe('devto:article:101') + ->and($interaction->metadata['title'])->toBe('PHP is awesome') + ->and($interaction->metadata['engagement_snapshot']['reactions'])->toBe(10); +}); + +test('updates engagement for existing interactions without creating duplicates', function (): void { + Http::fake([ + '*/articles?*page=1*' => Http::response([ + [ + 'id' => 201, + 'title' => 'Existing article', + 'url' => 'https://dev.to/author/existing', + 'published_at' => '2026-03-10T10:00:00Z', + 'created_at' => '2026-03-10T09:00:00Z', + 'tag_list' => ['php'], + 'public_reactions_count' => 50, + 'comments_count' => 10, + 'user' => ['username' => 'author'], + ], + ]), + '*/articles?*page=2*' => Http::response([]), + '*/articles/201' => Http::response([ + 'id' => 201, + 'public_reactions_count' => 50, + 'comments_count' => 10, + 'reading_list_count' => 5, + ]), + ]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'provider' => IdentityProvider::DevTo, + 'metadata' => ['email' => 'author@example.com', 'username' => 'author'], + ]); + + Interaction::factory()->recycle($character)->recycle($tenant)->create([ + 'external_ref' => 'devto:article:201', + 'metadata' => [ + 'engagement_snapshot' => ['reactions' => 20, 'comments' => 5, 'bookmarks' => 1], + ], + ]); + + $this->artisan('devto:sync-articles') + ->assertSuccessful(); + + expect(Interaction::query()->count())->toBe(1); + + $interaction = Interaction::query()->first(); + expect($interaction->metadata['engagement_snapshot']['reactions'])->toBe(50) + ->and($interaction->metadata['engagement_snapshot']['bookmarks'])->toBe(5); +}); diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Widgets/ExternalIdentityStatsOverview.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Widgets/ExternalIdentityStatsOverview.php index 6fc013d3..2452646a 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Widgets/ExternalIdentityStatsOverview.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Widgets/ExternalIdentityStatsOverview.php @@ -7,7 +7,7 @@ use Filament\Support\Icons\Heroicon; use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget\Stat; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; diff --git a/app-modules/panel-admin/src/Filament/Resources/Interactions/InteractionResource.php b/app-modules/panel-admin/src/Filament/Resources/Interactions/InteractionResource.php new file mode 100644 index 00000000..78d9f324 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Interactions/InteractionResource.php @@ -0,0 +1,41 @@ + ListInteractions::route('/'), + 'view' => ViewInteraction::route('/{record}'), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Interactions/Pages/ListInteractions.php b/app-modules/panel-admin/src/Filament/Resources/Interactions/Pages/ListInteractions.php new file mode 100644 index 00000000..8c047ca5 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Interactions/Pages/ListInteractions.php @@ -0,0 +1,13 @@ +components([ + Section::make('Interaction Details') + ->schema([ + TextEntry::make('type')->badge(), + TextEntry::make('provider')->badge(), + TextEntry::make('status')->badge(), + TextEntry::make('value_tier')->badge()->label('Tier'), + TextEntry::make('character.user.name')->label('User'), + TextEntry::make('coins_min')->label('Min Coins'), + TextEntry::make('coins_max')->label('Max Coins'), + TextEntry::make('coins_awarded')->label('Awarded'), + TextEntry::make('xp_awarded')->label('XP Awarded'), + TextEntry::make('external_ref')->label('External Ref'), + TextEntry::make('occurred_at')->dateTime('d/m/Y H:i'), + TextEntry::make('reviewed_at')->dateTime('d/m/Y H:i'), + ])->columns(3), + + Section::make('Metadata') + ->schema([ + TextEntry::make('metadata') + ->formatStateUsing(fn (?array $state): string => $state ? json_encode($state, JSON_PRETTY_PRINT) : 'N/A') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Interactions/Tables/InteractionsTable.php b/app-modules/panel-admin/src/Filament/Resources/Interactions/Tables/InteractionsTable.php new file mode 100644 index 00000000..afb7f845 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Interactions/Tables/InteractionsTable.php @@ -0,0 +1,123 @@ +columns([ + TextColumn::make('status') + ->badge() + ->sortable(), + + TextColumn::make('type') + ->label('Type') + ->sortable() + ->searchable(), + + TextColumn::make('provider') + ->badge() + ->sortable(), + + TextColumn::make('character.user.name') + ->label('User') + ->searchable(), + + TextColumn::make('value_tier') + ->badge() + ->label('Tier') + ->sortable(), + + TextColumn::make('coins_range') + ->label('Coins Range') + ->state(fn (Interaction $record): string => sprintf('%d-%d', $record->coins_min, $record->coins_max)), + + TextColumn::make('coins_awarded') + ->label('Awarded') + ->numeric() + ->sortable(), + + TextColumn::make('occurred_at') + ->label('Occurred') + ->dateTime('d/m/Y H:i') + ->sortable(), + ]) + ->defaultSort('occurred_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->options(ActivityStatus::class), + + SelectFilter::make('type') + ->options(ActivityType::class), + + SelectFilter::make('value_tier') + ->options(ValueTier::class), + ]) + ->recordActions([ + ViewAction::make(), + Action::make('approve') + ->label('Approve') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->visible(fn (Interaction $record): bool => $record->status === ActivityStatus::Pending) + ->action(fn (Interaction $record) => resolve(ApproveInteraction::class)->handle($record)), + + Action::make('reject') + ->label('Reject') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Interaction $record): bool => $record->status === ActivityStatus::Pending) + ->action(fn (Interaction $record) => resolve(RejectInteraction::class)->handle($record)), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_approve') + ->label('Approve Selected') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->action(function (Collection $records): void { + $approveAction = resolve(ApproveInteraction::class); + $records->each(fn (Interaction $record) => $record->status === ActivityStatus::Pending + ? $approveAction->handle($record) + : null + ); + }), + + BulkAction::make('bulk_reject') + ->label('Reject Selected') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->action(function (Collection $records): void { + $rejectAction = resolve(RejectInteraction::class); + $records->each(fn (Interaction $record) => $record->status === ActivityStatus::Pending + ? $rejectAction->handle($record) + : null + ); + }), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Messages/MessageResource.php b/app-modules/panel-admin/src/Filament/Resources/Messages/MessageResource.php index 7bbd8b0e..ecb24894 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Messages/MessageResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/Messages/MessageResource.php @@ -9,7 +9,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\PanelAdmin\Filament\Resources\Messages\Pages\ListMessages; use He4rt\PanelAdmin\Filament\Resources\Messages\Tables\MessagesTable; use UnitEnum; diff --git a/app-modules/panel-admin/src/Filament/Resources/Voices/VoiceResource.php b/app-modules/panel-admin/src/Filament/Resources/Voices/VoiceResource.php index 0b872515..f7fe9dd4 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Voices/VoiceResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/Voices/VoiceResource.php @@ -9,7 +9,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; -use He4rt\Activity\Models\Voice; +use He4rt\Activity\Voice\Models\Voice; use He4rt\PanelAdmin\Filament\Resources\Voices\Pages\ListVoices; use He4rt\PanelAdmin\Filament\Resources\Voices\Tables\VoicesTable; use UnitEnum; diff --git a/app-modules/panel-admin/src/Filament/Widgets/ActivityChart.php b/app-modules/panel-admin/src/Filament/Widgets/ActivityChart.php index 707f1156..655556e0 100644 --- a/app-modules/panel-admin/src/Filament/Widgets/ActivityChart.php +++ b/app-modules/panel-admin/src/Filament/Widgets/ActivityChart.php @@ -5,7 +5,7 @@ namespace He4rt\PanelAdmin\Filament\Widgets; use Filament\Widgets\ChartWidget; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; diff --git a/composer.json b/composer.json index ef32d8ac..76b50853 100644 --- a/composer.json +++ b/composer.json @@ -24,16 +24,17 @@ "he4rt/gamification": ">=1", "he4rt/he4rt-core": ">=1", "he4rt/identity": ">=1", + "he4rt/integration-devto": ">=1", "he4rt/integration-discord": ">=1", "he4rt/integration-twitch": ">=1", "he4rt/panel-admin": "^1.0", "he4rt/portal": ">=1", - "internachi/modular": "dev-main#ad95fe9", + "internachi/modular": "^3.0.2", "laracord/framework": "dev-next", "laravel/framework": "^12.55.1", "laravel/nightwatch": "^1.24.4", "laravel/sanctum": "^4.3.1", - "laravel/telescope": "^5.18.0", + "laravel/telescope": "^5.19.0", "laravel/tinker": "^2.11.1", "livewire/flux": "^2.13.0", "marvinlabs/laravel-discord-logger": "^1.4.3", @@ -52,10 +53,10 @@ "driftingly/rector-laravel": "^2.2.0", "fakerphp/faker": "^1.24.1", "larastan/larastan": "^3.9.3", - "laravel/boost": "^2.3.4", + "laravel/boost": "^2.4.1", "laravel/pail": "^1.2.6", "laravel/pint": "^1.29.0", - "laravel/sail": "^1.54.0", + "laravel/sail": "^1.55.0", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.1", "pestphp/pest": "^4.4.3", @@ -167,10 +168,6 @@ { "type": "vcs", "url": "https://github.com/danielhe4rt/laracord-framework" - }, - { - "type": "vcs", - "url": "https://github.com/gvieira18/modular" } ] } diff --git a/composer.lock b/composer.lock index b0dd74d6..5b7c158d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f49064900ba918d2aababcfdfe950f2f", + "content-hash": "466d55f64293c05f3edf2fb1aaffcda2", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -3353,6 +3353,38 @@ "relative": true } }, + { + "name": "he4rt/integration-devto", + "version": "1.0", + "dist": { + "type": "path", + "url": "app-modules/integration-devto", + "reference": "a4c27199e7e14ef5a965447d3cd3427cb8fbbe86" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationDevTo\\Providers\\IntegrationDevToServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "He4rt\\IntegrationDevTo\\": "src/", + "He4rt\\IntegrationDevTo\\Tests\\": "tests/", + "He4rt\\IntegrationDevTo\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationDevTo\\Database\\Seeders\\": "database/seeders/" + } + }, + "license": [ + "proprietary" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "he4rt/integration-discord", "version": "1.0", @@ -3483,45 +3515,43 @@ }, { "name": "internachi/modular", - "version": "dev-main", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/gvieira18/modular.git", - "reference": "ad95fe9" + "url": "https://github.com/InterNACHI/modular.git", + "reference": "939a341079c9cc79ff916663cb93f846da1c6456" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/gvieira18/modular/zipball/ad95fe9", - "reference": "ad95fe9", + "url": "https://api.github.com/repos/InterNACHI/modular/zipball/939a341079c9cc79ff916663cb93f846da1c6456", + "reference": "939a341079c9cc79ff916663cb93f846da1c6456", "shasum": "" }, "require": { "composer/composer": "^2.1", "ext-dom": "*", "ext-simplexml": "*", - "illuminate/support": "^9|^10|^11|^12|13.x-dev|dev-master|dev-main", - "php": ">=8.0" + "illuminate/support": "^11|^12|^13|14.x-dev|dev-master|dev-main", + "internachi/modularize": "^1.1.0", + "php": ">=8.3" }, "require-dev": { "ext-json": "*", "friendsofphp/php-cs-fixer": "^3.14", - "livewire/livewire": "^2.5|^3.0", "mockery/mockery": "^1.5", - "orchestra/testbench": "^7.52|^8.33|^9.11|^10.0|dev-master|dev-main", - "phpunit/phpunit": "^9.5|^10.5|^11.5" + "orchestra/testbench": "^9.11|^10.0|^11.0|dev-master|dev-main", + "phpunit/phpunit": "^10.5|^11.5|^12.5|^13.0" }, - "default-branch": true, "type": "library", "extra": { "laravel": { - "providers": [ - "InterNACHI\\Modular\\Support\\ModularServiceProvider", - "InterNACHI\\Modular\\Support\\ModularizedCommandsServiceProvider", - "InterNACHI\\Modular\\Support\\ModularEventServiceProvider" - ], "aliases": { "Modules": "InterNACHI\\Modular\\Support\\Facades\\Modules" - } + }, + "providers": [ + "InterNACHI\\Modular\\Support\\ModularServiceProvider", + "InterNACHI\\Modular\\Support\\ModularizedCommandsServiceProvider" + ] } }, "autoload": { @@ -3529,19 +3559,7 @@ "InterNACHI\\Modular\\": "src/" } }, - "autoload-dev": { - "psr-4": { - "InterNACHI\\Modular\\Tests\\": "tests/" - } - }, - "scripts": { - "fix-style": [ - "vendor/bin/php-cs-fixer fix" - ], - "check-style": [ - "vendor/bin/php-cs-fixer fix --diff --dry-run" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -3559,9 +3577,61 @@ "modules" ], "support": { - "source": "https://github.com/gvieira18/modular/tree/main" + "issues": "https://github.com/InterNACHI/modular/issues", + "source": "https://github.com/InterNACHI/modular/tree/3.0.2" }, - "time": "2025-12-14T14:31:19+00:00" + "time": "2026-03-23T15:21:38+00:00" + }, + { + "name": "internachi/modularize", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/InterNACHI/modularize.git", + "reference": "09965fcea17de5f5f84e6e1aae45f0973753be1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/InterNACHI/modularize/zipball/09965fcea17de5f5f84e6e1aae45f0973753be1a", + "reference": "09965fcea17de5f5f84e6e1aae45f0973753be1a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/support": "^10|^11|^12|^13|dev-main|dev-master" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.71", + "internachi/modular": "*", + "orchestra/testbench": "^8.34.0|^9.12.0|^10.1.0|^11.0.0|12.x-dev|dev-main|dev-master", + "phpunit/phpunit": "^10.5|^11.5|^12.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "InterNACHI\\Modularize\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Morrell", + "homepage": "https://www.cmorrell.com" + } + ], + "keywords": [ + "InterNACHI", + "laravel", + "modular" + ], + "support": { + "issues": "https://github.com/InterNACHI/modularize/issues", + "source": "https://github.com/InterNACHI/modularize/tree/1.1.1" + }, + "time": "2026-03-23T14:50:10+00:00" }, { "name": "intonate/tinker-zero", @@ -4163,16 +4233,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.15", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/4bb8107ec97651fd3f17f897d6489dbc4d8fb999", - "reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { @@ -4216,9 +4286,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.15" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-03-17T13:45:17+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/sanctum", @@ -4405,16 +4475,16 @@ }, { "name": "laravel/telescope", - "version": "v5.18.0", + "version": "v5.19.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "6e2aead19de0efb767f703559cc6539036b7fc59" + "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/6e2aead19de0efb767f703559cc6539036b7fc59", - "reference": "6e2aead19de0efb767f703559cc6539036b7fc59", + "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b", + "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b", "shasum": "" }, "require": { @@ -4422,8 +4492,8 @@ "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", "laravel/sentinel": "^1.0", "php": "^8.0", - "symfony/console": "^5.3|^6.0|^7.0", - "symfony/var-dumper": "^5.0|^6.0|^7.0" + "symfony/console": "^5.3|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "ext-gd": "*", @@ -4468,9 +4538,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.18.0" + "source": "https://github.com/laravel/telescope/tree/v5.19.0" }, - "time": "2026-03-05T15:53:11+00:00" + "time": "2026-03-24T18:37:14+00:00" }, { "name": "laravel/tinker", @@ -4820,16 +4890,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -4897,9 +4967,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-local", @@ -7374,16 +7444,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.21", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -7447,9 +7517,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.21" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-03-06T21:21:28+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -14082,16 +14152,16 @@ }, { "name": "laravel/boost", - "version": "v2.3.4", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "9e3dd5f05b59394e463e78853067dc36c63a0394" + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/9e3dd5f05b59394e463e78853067dc36c63a0394", - "reference": "9e3dd5f05b59394e463e78853067dc36c63a0394", + "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506", + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506", "shasum": "" }, "require": { @@ -14144,20 +14214,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-17T16:42:14+00:00" + "time": "2026-03-25T16:37:40+00:00" }, { "name": "laravel/mcp", - "version": "v0.6.3", + "version": "v0.6.4", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "8a2c97ec1184e16029080e3f6172a7ca73de4df9" + "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/8a2c97ec1184e16029080e3f6172a7ca73de4df9", - "reference": "8a2c97ec1184e16029080e3f6172a7ca73de4df9", + "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", + "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", "shasum": "" }, "require": { @@ -14217,7 +14287,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-03-12T12:46:43+00:00" + "time": "2026-03-19T12:37:13+00:00" }, { "name": "laravel/pail", @@ -14430,16 +14500,16 @@ }, { "name": "laravel/sail", - "version": "v1.54.0", + "version": "v1.55.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "bcc5e06f1a79d806d880a4b027964d2aa5872b07" + "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/bcc5e06f1a79d806d880a4b027964d2aa5872b07", - "reference": "bcc5e06f1a79d806d880a4b027964d2aa5872b07", + "url": "https://api.github.com/repos/laravel/sail/zipball/67dc1b72da4e066a2fb54c1c7582fd2f140ea191", + "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191", "shasum": "" }, "require": { @@ -14489,7 +14559,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-03-11T14:10:52+00:00" + "time": "2026-03-23T15:56:34+00:00" }, { "name": "mockery/mockery", @@ -15844,11 +15914,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.42", + "version": "2.1.44", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", - "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", "shasum": "" }, "require": { @@ -15893,7 +15963,7 @@ "type": "github" } ], - "time": "2026-03-17T14:58:32+00:00" + "time": "2026-03-25T17:34:21+00:00" }, { "name": "phpunit/php-code-coverage", @@ -17530,7 +17600,6 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "internachi/modular": 20, "laracord/framework": 20 }, "prefer-stable": true, diff --git a/config/services.php b/config/services.php index 24f30803..7b650816 100644 --- a/config/services.php +++ b/config/services.php @@ -49,4 +49,12 @@ 'enabled' => env('TWITCH_OAUTH_ENABLED', true), ], + 'devto' => [ + 'client_id' => env('DEVTO_OAUTH_CLIENT_ID'), + 'client_secret' => env('DEVTO_OAUTH_CLIENT_SECRET'), + 'redirect_uri' => env('DEVTO_OAUTH_REDIRECT_URI', 'https://localhost:8000/auth/oauth/devto'), + 'scopes' => env('DEVTO_OAUTH_SCOPES', 'public'), + 'enabled' => env('DEVTO_OAUTH_ENABLED', false), + ], + ]; diff --git a/database/seeders/BaseSeeder.php b/database/seeders/BaseSeeder.php index 2785a883..81025234 100644 --- a/database/seeders/BaseSeeder.php +++ b/database/seeders/BaseSeeder.php @@ -4,7 +4,7 @@ namespace Database\Seeders; -use He4rt\Activity\Models\Message; +use He4rt\Activity\Message\Models\Message; use He4rt\Community\Meeting\Models\Meeting; use He4rt\Events\Models\EventModel; use He4rt\Gamification\Character\Models\Character;