Skip to content

[feature request] activity tracking + devto integration #182

@danielhe4rt

Description

@danielhe4rt

A He4rt Developers está construindo um sistema econômico que recompensa contribuições da comunidade com coins. O primeiro passo é o Activity Tracking — a base que captura, classifica e roteia atividades pra serem recompensadas.

Problema atual: O módulo activity/ rastreia apenas mensagens e voice do Discord, com estrutura flat e sem modelo unificado de atividades. Não há classificação por valor, status de aprovação, nem suporte a múltiplos providers.

Objetivo: Reestruturar o módulo activity/ com sub-domínios, criar o modelo Interaction como registro unificado de atividades, e implementar DevTo como primeiro provider externo (artigos + engagement snapshot).

Escopo MVP: Apenas DevTo como provider. Discord adapter e outros ficam pra depois.

┌─────────────────────────────────────────────────────────────────┐
│                    ARQUITETURA ALVO                               │
│                                                                   │
│  integration-devto/            activity/                          │
│  ┌─────────────┐    DTO    ┌──────────────┐    Economy           │
│  │ SyncArticles│──────────→│TrackActivity │──────────→ Credit    │
│  │ (scheduled) │           │(entry point) │                      │
│  └─────────────┘           └──────┬───────┘                      │
│         │                         │                               │
│         │ OAuth                   │ ClassifyActivity              │
│         ▼                         ▼                               │
│  identity/               ┌───────────────┐                       │
│  ┌────────────┐          │  Interaction   │                      │
│  │ExternalId  │←─────────│  (persisted)   │                      │
│  │(DevTo user)│          └───────────────┘                       │
│  └────────────┘                                                   │
└─────────────────────────────────────────────────────────────────┘

Step 1: Reestruturar módulo activity/ em sub-domínios

Context

O módulo activity/ tem todos os arquivos flat em src/ (Actions, DTOs, Models, Http). Precisa ser reorganizado em sub-domínios Message/, Voice/ e Tracking/ para acomodar o novo pipeline. Os arquivos a mover:

Message/ (mover de src/):

  • src/Models/Message.phpsrc/Message/Models/Message.php
  • src/Actions/NewMessage.phpsrc/Message/Actions/NewMessage.php
  • src/Actions/PersistMessage.phpsrc/Message/Actions/PersistMessage.php
  • src/DTOs/NewMessageDTO.phpsrc/Message/DTOs/NewMessageDTO.php
  • src/Http/Controllers/MessagesController.phpsrc/Message/Http/Controllers/MessagesController.php
  • src/Http/Requests/CreateMessageRequest.phpsrc/Message/Http/Requests/CreateMessageRequest.php
  • src/Http/Requests/CreateVoiceMessageRequest.phpsrc/Voice/Http/Requests/CreateVoiceMessageRequest.php
  • src/Filament/Admin/Resources/Messages/*src/Message/Filament/Admin/Resources/Messages/*

Voice/ (mover de src/):

  • src/Models/Voice.phpsrc/Voice/Models/Voice.php
  • src/Actions/NewVoiceMessage.phpsrc/Voice/Actions/NewVoiceMessage.php
  • src/DTOs/NewVoiceMessageDTO.phpsrc/Voice/DTOs/NewVoiceMessageDTO.php

Namespaces afetados:

  • He4rt\Activity\Models\MessageHe4rt\Activity\Message\Models\Message
  • He4rt\Activity\Models\VoiceHe4rt\Activity\Voice\Models\Voice
  • He4rt\Activity\Actions\*He4rt\Activity\Message\Actions\* / He4rt\Activity\Voice\Actions\*
  • He4rt\Activity\DTOs\*He4rt\Activity\Message\DTOs\* / He4rt\Activity\Voice\DTOs\*

Arquivos que referenciam os namespaces antigos (atualizar):

  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/src/Providers/ActivityServiceProvider.php
  • app-modules/activity/database/factories/MessageFactory.php
  • app-modules/bot-discord/src/Events/MessageReceivedEvent.php (importa NewMessage/NewMessageDTO)
  • app-modules/community/ (se referencia Message model)
  • Todos os testes em app-modules/activity/tests/

Expected Behavior

Dado que o módulo activity tem arquivos flat em src/
Quando eu reorganizo em sub-domínios Message/, Voice/
Então todos os namespaces são atualizados para He4rt\Activity\{Message,Voice}\*
E composer dump-autoload executa sem erros
E todos os testes existentes passam com os novos namespaces
E o MessageReceivedEvent do bot-discord usa o novo namespace
E as rotas POST /api/messages/{provider} e POST /api/voices/{provider} continuam funcionando
E o Filament admin MessageResource renderiza corretamente

Step 2: Criar sub-domínio Tracking/ com modelo Interaction

Context

O sub-domínio Tracking/ é o core do Activity Tracking. O modelo Interaction é o registro unificado de qualquer atividade rastreada, com classificação por valor, status de aprovação, e referência polimórfica à fonte.

Criar em app-modules/activity/src/Tracking/:

Tracking/
  Models/
    Interaction.php
  Actions/
    TrackActivity.php          ← ponto de entrada único
    ClassifyActivity.php       ← determina tier + coin range
    CalculateReward.php        ← formula: base + engagement bonus
    ApproveInteraction.php     ← aprovação manual (interim)
    RejectInteraction.php
  DTOs/
    TrackActivityDTO.php
  Enums/
    ActivityType.php            ← article, pr_merged, mentoring, message, voice...
    ActivityStatus.php          ← pending, auto_approved, in_review, approved, rejected
    ValueTier.php               ← high, medium, low
  Contracts/
    ActivitySourceContract.php  ← interface que cada provider implementa
  Events/
    InteractionTracked.php
    InteractionApproved.php
  Concerns/
    HasInteractions.php         ← trait pro Character model

Migration create_interactions_table:

CREATE TABLE interactions (
    id              UUID PRIMARY KEY,
    character_id    UUID NOT NULL REFERENCES characters(id),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    type            VARCHAR NOT NULL,          -- ActivityType enum
    provider        VARCHAR NOT NULL,          -- IdentityProvider enum
    value_tier      VARCHAR NOT NULL,          -- ValueTier enum
    coins_min       INTEGER NOT NULL,
    coins_max       INTEGER NOT NULL,
    coins_awarded   INTEGER NULL,              -- set after approval
    xp_awarded      INTEGER NULL,              -- set after approval
    status          VARCHAR NOT NULL DEFAULT 'pending',
    source_type     VARCHAR NULL,              -- polymorphic
    source_id       UUID NULL,                 -- polymorphic
    external_ref    VARCHAR NULL UNIQUE,        -- deduplication key
    metadata        JSONB NULL,                -- provider-specific data
    occurred_at     TIMESTAMP NOT NULL,
    reviewed_at     TIMESTAMP NULL,
    created_at      TIMESTAMP NOT NULL,
    updated_at      TIMESTAMP NOT NULL
);

CREATE INDEX idx_interactions_character_type ON interactions(character_id, type, created_at);
CREATE INDEX idx_interactions_status_tier ON interactions(status, value_tier);
CREATE INDEX idx_interactions_tenant ON interactions(tenant_id, occurred_at);
CREATE INDEX idx_interactions_source ON interactions(source_type, source_id);

ActivityType enum — valores iniciais:

enum ActivityType: string {
    case Article = 'article';
    case PrMerged = 'pr_merged';
    case Mentoring = 'mentoring';
    case SquadProject = 'squad_project';
    case Referral = 'referral';
    case PeerReview = 'peer_review';
    case CallParticipation = 'call_participation';
    case ForumDebate = 'forum_debate';
    case ContentShare = 'content_share';
    case Engagement = 'engagement';
    case RepoStar = 'repo_star';
    case Message = 'message';
    case Voice = 'voice';
}

Tabela de classificação (config-driven, não hardcoded):

// config/activity-tracking.php
'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],
],
'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,
],

TrackActivity Action (ponto de entrada):

1. Recebe TrackActivityDTO
2. Verifica dedup via external_ref (se já existe, skip)
3. Resolve Character via character_id ou ExternalIdentity
4. Chama ClassifyActivity → retorna tier, coins_min, coins_max, status
5. Persiste Interaction
6. Se status == auto_approved → chama CalculateReward + Economy::Credit
7. Dispara InteractionTracked event

CalculateReward Action:

1. Recebe Interaction
2. Se tem metadata.engagement_snapshot:
   base = peer_review_score (ou midpoint se auto-approved)
   bonus = reactions * mult + bookmarks * mult + comments * mult (com caps)
   coins_awarded = min(base + bonus, coins_max)
3. Se não tem engagement:
   coins_awarded = para auto_approved → coins_min
                   para peer_reviewed → baseado no score
4. xp_awarded = coins_awarded * XP_MULTIPLIER (config)
5. Atualiza Interaction com coins_awarded e xp_awarded

Expected Behavior

Dado um TrackActivityDTO válido com type='article' e provider='devto'
Quando TrackActivity é chamado
Então uma nova Interaction é criada com status 'pending' (high tier)
E value_tier é 'high', coins_min é 100, coins_max é 300
E o evento InteractionTracked é disparado
E Economy::Credit NÃO é chamado (high tier precisa de review)

Dado um TrackActivityDTO válido com type='engagement' e provider='devto'
Quando TrackActivity é chamado
Então uma nova Interaction é criada com status 'auto_approved' (low tier)
E CalculateReward é chamado
E Economy::Credit é chamado com coins_min (1) pra wallet do character
E o evento InteractionTracked é disparado

Dado um TrackActivityDTO com external_ref que já existe no banco
Quando TrackActivity é chamado
Então nenhuma nova Interaction é criada (deduplicação)
E nenhuma exceção é lançada (idempotente)

Dado uma Interaction aprovada com engagement_snapshot {reactions: 42, bookmarks: 8, comments: 12}
Quando CalculateReward é chamado com peer_review_base = 200
Então engagement_bonus = min(42*0.5, 25) + min(8*1.0, 15) + min(12*2.0, 30) = 21 + 8 + 24 = 53
E coins_awarded = min(200 + 53, 300) = 253
E xp_awarded = 253 * XP_MULTIPLIER

Step 3: Criar módulo integration-devto/

Context

O DevTo é o primeiro provider externo. Segue o padrão existente de integration-discord/ e integration-twitch/: módulo separado com OAuth client, API client, e lógica de polling. O módulo não contém lógica de tracking — apenas normaliza dados do DevTo e os empurra pro TrackActivity do módulo activity/.

Estrutura:

app-modules/integration-devto/
  src/
    OAuth/
      DevToOAuthClient.php          ← implements OAuthClientContract
      DevToOAuthAccessDTO.php       ← extends OAuthAccessDTO
      DevToOAuthUser.php            ← extends OAuthUserDTO
    Polling/
      SyncDevToArticles.php         ← artisan command (scheduled)
      DevToApiClient.php            ← wrapper HTTP para DevTo API
    Providers/
      IntegrationDevToServiceProvider.php
  config/
    integration-devto.php
  tests/
    Feature/
      DevToOAuthTest.php
      SyncDevToArticlesTest.php
  composer.json

DevToApiClient — endpoints usados:

  • GET /api/articles?username={org_slug}&per_page=30&page={page} — artigos da org
  • GET /api/articles/{id} — detalhes do artigo (engagement metrics)

SyncDevToArticles Command:

1. Busca artigos da org He4rt no DevTo (paginado)
2. Para cada artigo:
   a. Verifica se autor tem ExternalIdentity DevTo vinculada
   b. Se não tem → log e skip
   c. Se já tem Interaction com external_ref "devto:article:{id}" → atualiza engagement no metadata
   d. Se é novo → cria TrackActivityDTO e chama TrackActivity
3. Roda via scheduler a cada 30 min (configurável)

Config (config/integration-devto.php):

return [
    'org_slug' => env('DEVTO_ORG_SLUG', 'he4rt'),
    'api_base_url' => env('DEVTO_API_URL', 'https://dev.to/api'),
    'polling_interval_minutes' => env('DEVTO_POLLING_INTERVAL', 30),
];

DevTo OAuth (Forem API):

  • Base URL: https://dev.to/oauth/authorize
  • Token URL: https://dev.to/oauth/token
  • User URL: https://dev.to/api/users/me
  • Scopes: public

Implementar seguindo o padrão de integration-discord/:

  • DevToOAuthClient implementa OAuthClientContract
  • redirectUrl(?OAuthStateDTO) → redireciona pro DevTo OAuth
  • auth(string $code) → troca code por token
  • getAuthenticatedUser(OAuthAccessDTO) → busca user info

Expected Behavior

Dado que a org "he4rt" no DevTo tem 5 artigos publicados
E 3 dos autores têm ExternalIdentity DevTo vinculada
Quando php artisan devto:sync-articles executa
Então 3 Interactions são criadas (uma por autor vinculado)
E 2 artigos são logados como "autor não vinculado" e ignorados
E cada Interaction tem external_ref "devto:article:{id}"
E metadata contém devto_article_id, title, url, tags, engagement_snapshot

Dado que o artigo "devto:article:123" já tem uma Interaction
Quando php artisan devto:sync-articles executa novamente
Então nenhuma nova Interaction é criada pro artigo 123
E o engagement_snapshot no metadata da Interaction existente é atualizado
E o coins_awarded NÃO é recalculado (só acontece no momento do review)

Dado que um membro He4rt clica "Conectar DevTo" no perfil
Quando ele completa o fluxo OAuth
Então uma nova ExternalIdentity é criada com provider=devto
E o username e avatar do DevTo são armazenados
E syncs futuros de artigos vão detectar os artigos desse usuário

Step 4: Modificar módulo identity/ — adicionar DevTo ao IdentityProvider

Context

O enum IdentityProvider em app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php só tem Discord e Twitch. Precisa adicionar DevTo com as configs correspondentes (icon, color, description, client binding).

Arquivos a modificar:

  • app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php — adicionar case DevTo = 'devto'
  • config/services.php — adicionar bloco devto com OAuth credentials
  • IntegrationDevToServiceProvider — bind DevToOAuthClient

No enum, adicionar métodos pra DevTo:

  • getClient() → retorna DevToOAuthClient
  • getColor() → preto (#000000)
  • getIcon() → heroicon adequado
  • getDescription() → "Dev.to"
  • getScopes() → busca de config('services.devto.scopes')
  • isEnabled() → busca de config('services.devto.enabled')

Expected Behavior

Dado que o enum IdentityProvider existe com Discord e Twitch
Quando o case DevTo é adicionado
Então IdentityProvider::DevTo->value é igual a 'devto'
E IdentityProvider::DevTo->getClient() retorna instância de DevToOAuthClient
E a URL de redirect OAuth pra DevTo funciona corretamente
E os fluxos existentes de Discord/Twitch não são afetados

Step 5: Admin Filament — InteractionResource (aprovação manual interim)

Context

Enquanto o módulo de peer review não existe, o admin precisa visualizar e aprovar/rejeitar Interactions manualmente via Filament. Criar InteractionResource no painel Admin.

Criar em app-modules/activity/src/Tracking/Filament/Admin/Resources/Interactions/:

  • InteractionResource.php
  • Pages/ListInteractions.php
  • Pages/ViewInteraction.php (view-only, sem edit genérico)
  • Schemas/InteractionInfolist.php (exibição de detalhes)
  • Tables/InteractionsTable.php

Tabela deve mostrar:

  • Status (badge colorido)
  • Tipo (ActivityType)
  • Provider
  • Character (nome do usuário)
  • Value tier
  • Coins range (min-max)
  • Coins awarded (se aprovado)
  • Occurred at
  • Filtros: status, type, provider, value_tier

Actions na tabela:

  • Approve (bulk + individual) — chama ApproveInteraction com CalculateReward
  • Reject (bulk + individual) — chama RejectInteraction

Expected Behavior

Dado que existem 10 Interactions com status 'pending'
Quando o admin abre /admin/interactions
Então todas as 10 são listadas com status, tipo, provider e range de coins
E o admin pode filtrar por status='pending'
E o admin pode selecionar múltiplas e aprovar em bulk

Dado que o admin clica "Aprovar" em uma Interaction pendente de artigo
Quando ApproveInteraction é chamado
Então CalculateReward calcula coins_awarded a partir do engagement snapshot
E Economy::Credit é chamado pra wallet do character
E Gamification::IncrementExperience é chamado
E o status da Interaction muda para 'approved'
E reviewed_at é definido como now()

Step 6: Conectar com Economy e Gamification

Context

Quando uma Interaction é aprovada (manual ou auto), o sistema precisa creditar coins na wallet do Character e incrementar XP. Usa as Actions existentes do módulo economy/ (Credit) e gamification/ (IncrementExperience).

ApproveInteraction Action:

1. Recebe Interaction
2. Chama CalculateReward → define coins_awarded e xp_awarded
3. Resolve Character → getOrCreateWallet()
4. Economy\Actions\Credit(CreditDTO) com:
   - walletId: character.wallet.id
   - amount: coins_awarded
   - referenceType: Interaction::class
   - referenceId: interaction.id
   - description: "Reward: {ActivityType->label}"
5. Gamification\Actions\IncrementExperience com xp_awarded
6. Atualiza Interaction: status=approved, reviewed_at=now()
7. Dispara InteractionApproved event

HasInteractions trait (adicionar ao Character):
Segue o padrão verificado em Character::pastSeasons() (@return HasMany<PastSeason, $this>):

trait HasInteractions {
    /**
     * @return HasMany<Interaction, $this>
     */
    public function interactions(): HasMany
    {
        return $this->hasMany(Interaction::class);
    }
}

Expected Behavior

Dado um Character com wallet balance 0 e experience 500
E uma Interaction pendente type='article' com coins_min=100, coins_max=300
E engagement_snapshot com reactions=42, bookmarks=8, comments=12
Quando ApproveInteraction é chamado com peer_review_base=200
Então coins_awarded = 253 (200 base + 53 engagement bonus, limitado a 300)
E o balance da wallet passa a ser 253
E existe um registro Transaction com type=Reward, amount=253, reference=Interaction
E a experience do character aumenta em xp_awarded
E o status da Interaction é 'approved'
E o evento InteractionApproved é disparado

Step 7: Testes

Context

Testes unitários para cada Action, testes de feature para o pipeline completo, e testes de integração para o sync do DevTo.

Testes a criar:

app-modules/activity/tests/
  Unit/
    Tracking/
      TrackActivityTest.php
      ClassifyActivityTest.php
      CalculateRewardTest.php
      ApproveInteractionTest.php
      RejectInteractionTest.php
  Feature/
    Tracking/
      InteractionPipelineTest.php     ← end-to-end: DTO → Interaction → Credit
      InteractionDeduplicationTest.php
    Filament/Admin/
      Interactions/
        ListInteractionsTest.php
        ApproveInteractionTest.php

app-modules/integration-devto/tests/
  Feature/
    SyncDevToArticlesTest.php         ← mock DevTo API, verify Interactions created
    DevToOAuthTest.php                ← test OAuth flow

Expected Behavior

Dado que todos os testes existentes passam antes das mudanças
Quando a reestruturação está completa
Então todos os testes existentes de Message/Voice passam com os novos namespaces
E todos os novos testes de Tracking passam
E todos os testes de integração DevTo passam
E php artisan test --compact mostra verde

Verificação end-to-end

  1. composer dump-autoload — sem erros
  2. php artisan test --compact — todos os testes passam
  3. vendor/bin/pint --dirty --format agent — código formatado
  4. php artisan migrate — migration da tabela interactions roda
  5. php artisan devto:sync-articles — executa sem erros (com DevTo API mockada em dev)
  6. Admin panel /admin/interactions — lista Interactions, approve/reject funcionam
  7. Após approve, verificar wallet balance incrementado e transaction criada
  8. OAuth DevTo flow — redirect + callback funcionam

Arquivos críticos a modificar/criar

Modificar:

  • app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php
  • app-modules/activity/src/Providers/ActivityServiceProvider.php
  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/database/factories/MessageFactory.php
  • app-modules/bot-discord/src/Events/MessageReceivedEvent.php
  • app-modules/gamification/src/Character/Models/Character.php
  • config/services.php
  • bootstrap/providers.php

Criar (novos):

  • app-modules/activity/src/Tracking/ (inteiro sub-domínio)
  • app-modules/activity/database/migrations/xxxx_create_interactions_table.php
  • app-modules/activity/config/activity-tracking.php
  • app-modules/integration-devto/ (inteiro módulo)
  • Todos os testes listados no Step 7

Mover (reorganizar):

  • app-modules/activity/src/Models/src/Message/Models/ e src/Voice/Models/
  • app-modules/activity/src/Actions/src/Message/Actions/ e src/Voice/Actions/
  • app-modules/activity/src/DTOs/src/Message/DTOs/ e src/Voice/DTOs/
  • app-modules/activity/src/Http/src/Message/Http/ e src/Voice/Http/
  • app-modules/activity/src/Filament/src/Message/Filament/

Reutilizar (já existem):

  • He4rt\Economy\Actions\Credit — creditar coins
  • He4rt\Economy\Actions\CreateWallet — criar wallet
  • He4rt\Economy\DTOs\CreditDTO — DTO do credit
  • He4rt\Economy\Concerns\HasWallet — trait do Character
  • He4rt\Gamification\Character\Actions\IncrementExperience — incrementar XP
  • He4rt\Identity\ExternalIdentity\Actions\ResolveExternalIdentity — resolver provider
  • He4rt\Identity\Auth\DTOs\OAuthStateDTO — state do OAuth
  • OAuthClientContract em app/Contracts/OAuthClientContract.php
  • OAuthAccessDTO e OAuthUserDTO em app-modules/identity/src/Auth/DTOs/

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions