diff --git a/.ai/guidelines/domain/03-multi-tenancy.blade.php b/.ai/guidelines/domain/03-multi-tenancy.blade.php deleted file mode 100644 index 3855d1754..000000000 --- a/.ai/guidelines/domain/03-multi-tenancy.blade.php +++ /dev/null @@ -1,29 +0,0 @@ -@php -/** @var \Laravel\Boost\Install\GuidelineAssist $assist */ -@endphp - -# Multi-Tenancy - -Filament-native tenancy. Tenants are Discord guilds / organizations. - -## Resolution - -Filament resolves the tenant from the URL slug (`/{panel}/{tenant-slug}/…`). Both Admin and App panels configure `->tenant(Tenant::class, slugAttribute: 'slug')` in their panel providers. - -## Data isolation - -Models carry a `tenant_id` FK with a `tenant()` BelongsTo relationship — each model defines its own, there is no shared trait. - -The Admin panel registers `ApplyTenantScopes` tenant middleware that adds `whereBelongsTo($tenant)` global scopes to models listed in `config('panel-admin.tenant_scoped_models')`. Filament also auto-scopes any model that has a Resource registered in a tenant-enabled panel. - -## In tests - -@verbatim - -$tenant = Tenant::factory()->create(); -$user = User::factory()->create(); -$model = SomeModel::factory()->recycle($tenant)->recycle($user)->create(); - -@endverbatim - -Use `->recycle($tenant)` to propagate the tenant ID through factory chains. diff --git a/.ai/guidelines/domain/04-model-phpdoc-sync.blade.php b/.ai/guidelines/domain/04-model-phpdoc-sync.blade.php index b75a87810..a969842b1 100644 --- a/.ai/guidelines/domain/04-model-phpdoc-sync.blade.php +++ b/.ai/guidelines/domain/04-model-phpdoc-sync.blade.php @@ -26,7 +26,7 @@ /** * @property string $id - * @property int $tenant_id + * @property int $position * @property string $name * @property string|null $description * @property bool $active diff --git a/.ai/guidelines/workflow/05-pull-requests.blade.php b/.ai/guidelines/workflow/05-pull-requests.blade.php new file mode 100644 index 000000000..12fd4a9b1 --- /dev/null +++ b/.ai/guidelines/workflow/05-pull-requests.blade.php @@ -0,0 +1,21 @@ +@php +/** @var \Laravel\Boost\Install\GuidelineAssist $assist */ +@endphp + +# Pull Requests + +When creating or updating a GitHub pull request, agents should consider the repository PR template at `.github/pull_request_template.md` as a helpful reference for structuring the PR body. + +## Guidance + +- Prefer using the template sections `Contexto`, `Alterações`, `Plano de Testes`, `Evidências` and `Issues Relacionadas` when they fit the change. +- Keep the structure clear and review-friendly, rather than writing a completely free-form PR description. +- Populate the checklist in `Plano de Testes` with the validation steps that were actually run, including relevant commands such as `make check` and `make test` when applicable. +- Add issue references in the `Issues Relacionadas` section using the repository's expected format (for example: `Closes #123`) when there is a related issue to link. +- If the change has no visible UI impact, keep the `Evidências` section concise or leave it empty rather than inventing screenshots. + +## Expectations for agents + +- It is fine to draft a PR body without the template; the absence of the template does not block the PR. +- Adapt the content to the specific change and prefer clear, concise Portuguese when the repository and existing templates are written in Portuguese. +- If the PR is for a change that affects behavior, include enough detail for reviewers to understand the impact, the validation performed, and the related issues. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..f448ab548 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Reporte um comportamento inesperado para nos ajudar a melhorar +title: '' +labels: bug +assignees: '' +type: Bug + +--- + +## Descrição do problema + + + +## Comportamento esperado + + + +## Comportamento atual + + + +## Passos para reproduzir + +1. Vá até '...' +2. Clique em '...' +3. Preencha '...' +4. Veja o erro + + +## Ambiente + +- **Sistema operacional:** +- **Navegador / versão:** +- **Ambiente:** + + +## Evidências + + + +## Sugestão de correção (opcional) + + + +## Contexto adicional + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..9ec38b660 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,56 @@ +--- +name: Feature request +about: Descreva uma feature a ser construída, com critérios de aceite e testes +title: '' +labels: type:feat +assignees: '' +type: Feature + +--- + +## Parent + + + + +## O que construir + +- +- +- + + +## Critérios de aceite + +- [ ] +- [ ] +- [ ] + + +## Teste + +### BDD + +```gherkin +# language: pt + +Funcionalidade: + + Cenário: + Dado + E + Quando + Então + E + + Cenário: + Dado + Então +``` + + +## Bloqueada por + +- [ ] # +- [ ] # diff --git "a/.github/ISSUE_TEMPLATE/prd---m\303\263dulo-de-dom\303\255nio.md" "b/.github/ISSUE_TEMPLATE/prd---m\303\263dulo-de-dom\303\255nio.md" new file mode 100644 index 000000000..cd2396f29 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/prd---m\303\263dulo-de-dom\303\255nio.md" @@ -0,0 +1,106 @@ +--- +name: PRD / Módulo de Domínio +about: Documento de requisitos para uma nova feature ou módulo de domínio +title: '' +labels: prd +assignees: '' + +--- + +# + +> Uma linha resumindo o que este documento entrega. + +--- + +## Descrição do problema + + + +## Solução + + + +## User Stories + + +- Como ****, quero ****, para que ****. +- Como ****, quero ****, para que ****. +- Como ****, quero ****, para que ****. + + +## Decisões de implementação + + +### Módulo / Namespace + + +### Autoridade & Autorização + + +### Modelo de dados + + +| Tabela | Colunas-chave | Constraints / Índices | Observações | +|--------|---------------|-----------------------|-------------| +| `` | `` | `` | `` | + +### Enums + + +### Gates / Pré-condições + + +### Actions + + +### Invariantes + + + +## Decisões de teste + + +- **:** +- **:** +- **:** + +**Prior art / referências:** + + +## Fora de escopo + + +- +- + + +## 📝 Notas adicionais + + +- **Follow-up:** +- **Evolução:** + + +## Subtarefas + + +## Dúvidas/Incertezas + + +--- + +### 🔗 Referências + + +- ADR-XXXX — +- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..52b94f3e7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,67 @@ +## Contexto + + + +### Alterações + + + +- ... +- ... +- ... + +--- + +## Plano de Testes + + + +- [ ] Executar `make check` +- [ ] Executar `make test` +- [ ] ... +- [ ] ... +- [ ] ... + +--- + +## Evidências + + + +### Antes + + + +### Depois + + + +--- + +## Issues Relacionadas + + diff --git a/.github/workflows/_pest.yml b/.github/workflows/_pest.yml index 3f8ea4983..b458e9555 100644 --- a/.github/workflows/_pest.yml +++ b/.github/workflows/_pest.yml @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -83,7 +83,7 @@ jobs: custom-cache-key: ${{ inputs.CACHE_KEY }} - name: Install Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: package-manager-cache: false node-version-file: '.nvmrc' diff --git a/.github/workflows/_phpstan.yml b/.github/workflows/_phpstan.yml index 38afe24f9..f8ff4973a 100644 --- a/.github/workflows/_phpstan.yml +++ b/.github/workflows/_phpstan.yml @@ -33,7 +33,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/_pint.yml b/.github/workflows/_pint.yml index 704c43b88..b73c0fbbf 100644 --- a/.github/workflows/_pint.yml +++ b/.github/workflows/_pint.yml @@ -33,7 +33,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/_rector.yml b/.github/workflows/_rector.yml index b502b6530..6c27cf84d 100644 --- a/.github/workflows/_rector.yml +++ b/.github/workflows/_rector.yml @@ -33,7 +33,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 80840536f..723713cf4 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -33,7 +33,7 @@ jobs: COMPOSER_FLAGS: ${{ steps.setup.outputs.COMPOSER_FLAGS }} steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.gitignore b/.gitignore index abdcf3a55..b6bbc22a8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ /storage/debugbar /storage/pail /vendor +/.cache Homestead.json Homestead.yaml *.local diff --git a/Makefile b/Makefile index 32bb6529c..8bb67106d 100644 --- a/Makefile +++ b/Makefile @@ -10,15 +10,11 @@ route-list: ## List all registered routes .PHONY: pint pint: ## Run Pint code style fixer - @export XDEBUG_MODE=off - @$(CURDIR)/vendor/bin/pint --parallel - @unset XDEBUG_MODE + @XDEBUG_MODE=off $(CURDIR)/vendor/bin/pint --parallel .PHONY: test-pint test-pint: ## Run Pint code style fixer in test mode - @export XDEBUG_MODE=off - @$(CURDIR)/vendor/bin/pint --test --parallel - @unset XDEBUG_MODE=off + @XDEBUG_MODE=off $(CURDIR)/vendor/bin/pint --test --parallel .PHONY: rector rector: ## Run Rector @@ -37,7 +33,7 @@ p: phpstan ## Alias for phpstan .PHONY: test-phpstan test-phpstan: ## Run PHPStan in test mode - @$(CURDIR)/vendor/bin/phpstan analyse --ansi + @$(CURDIR)/vendor/bin/phpstan analyse --ansi --memory-limit=2G .PHONY: format format: rector pint ## Run Pint and Rector and try to fixes the source code @@ -76,7 +72,7 @@ env-up: ## Start the development environment @docker compose --file docker-compose.yml up --detach .PHONY: env-down -env-down: ## Start the development environment +env-down: ## Stop the development environment (removes images and volumes) @docker compose --file docker-compose.yml down --rmi all --volumes .PHONY: dev @@ -95,6 +91,13 @@ import-db: ## Import a PostgreSQL dump file (usage: make import-db file=path/to/ bot: ## Run the Discord bot @php artisan bot:boot +# Fixes the boot error "failed to initialize voice class / +# LibDaveNotFoundException: libdave is required but could not be loaded" — since +# 2026-03-01 Discord mandates the DAVE E2EE protocol for voice. +.PHONY: libdave +libdave: ## Link libdave (DAVE E2EE) into the Discord bot voice probe path + @mkdir -p "$(CURDIR)/.cache" && ln -sfn "$${LIBDAVE_HOME:-$$HOME/.local/lib/libdave}" "$(CURDIR)/.cache/libdave" + .PHONY: truncate truncate: ## Truncate laravel.log file @truncate -s 0 storage/logs/laravel.log diff --git a/app-modules/activity/database/factories/InteractionFactory.php b/app-modules/activity/database/factories/InteractionFactory.php index b9f80b5e3..e31dd9b18 100644 --- a/app-modules/activity/database/factories/InteractionFactory.php +++ b/app-modules/activity/database/factories/InteractionFactory.php @@ -10,7 +10,6 @@ use He4rt\Activity\Tracking\Models\Interaction; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -24,7 +23,6 @@ public function definition(): array { return [ 'character_id' => Character::factory(), - 'tenant_id' => Tenant::factory(), 'type' => ActivityType::Article, 'provider' => IdentityProvider::DevTo, 'value_tier' => ValueTier::High, diff --git a/app-modules/activity/database/factories/MessageFactory.php b/app-modules/activity/database/factories/MessageFactory.php index d8ea1e204..dd3a9f722 100644 --- a/app-modules/activity/database/factories/MessageFactory.php +++ b/app-modules/activity/database/factories/MessageFactory.php @@ -6,7 +6,6 @@ use He4rt\Activity\Message\Models\Message; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -20,7 +19,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'external_identity_id' => ExternalIdentity::factory(), 'provider_message_id' => fake()->randomNumber(4), 'channel_id' => fake()->randomNumber(4), diff --git a/app-modules/activity/database/factories/TimelineFactory.php b/app-modules/activity/database/factories/TimelineFactory.php index be23b698d..2a047b588 100644 --- a/app-modules/activity/database/factories/TimelineFactory.php +++ b/app-modules/activity/database/factories/TimelineFactory.php @@ -6,7 +6,6 @@ use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -20,7 +19,6 @@ public function definition(): array { return [ 'user_id' => User::factory(), - 'tenant_id' => Tenant::factory(), 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory(), 'is_ignored' => false, diff --git a/app-modules/activity/database/factories/VoiceFactory.php b/app-modules/activity/database/factories/VoiceFactory.php index dcaa6af97..42de524cd 100644 --- a/app-modules/activity/database/factories/VoiceFactory.php +++ b/app-modules/activity/database/factories/VoiceFactory.php @@ -7,7 +7,6 @@ use He4rt\Activity\Voice\Enums\VoicePresenceEnum; use He4rt\Activity\Voice\Models\Voice; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -20,7 +19,6 @@ final class VoiceFactory extends Factory public function definition(): array { return [ - 'tenant_id' => Tenant::factory(), 'external_identity_id' => ExternalIdentity::factory(), 'channel_name' => fake()->word(), 'channel_id' => (string) fake()->randomNumber(9), diff --git a/app-modules/activity/database/migrations/2026_07_15_001258_recreate_activity_post_tenancy_indexes.php b/app-modules/activity/database/migrations/2026_07_15_001258_recreate_activity_post_tenancy_indexes.php new file mode 100644 index 000000000..9cfe19fcf --- /dev/null +++ b/app-modules/activity/database/migrations/2026_07_15_001258_recreate_activity_post_tenancy_indexes.php @@ -0,0 +1,35 @@ + $messageDTO->tenantId, 'provider' => $messageDTO->provider, 'external_account_id' => $messageDTO->externalAccountId, 'model_type' => (new User)->getMorphClass(), @@ -47,7 +46,6 @@ public function persist(NewMessageDTO $messageDTO): void } catch (Throwable $throwable) { Log::channel('bot-discord')->error('NewMessage failed', [ 'external_account_id' => $messageDTO->externalAccountId, - 'tenant_id' => $messageDTO->tenantId, 'exception' => $throwable, ]); } diff --git a/app-modules/activity/src/Message/Actions/PersistMessage.php b/app-modules/activity/src/Message/Actions/PersistMessage.php index 0e1f1175f..7f31f00c8 100644 --- a/app-modules/activity/src/Message/Actions/PersistMessage.php +++ b/app-modules/activity/src/Message/Actions/PersistMessage.php @@ -15,7 +15,6 @@ public function handle( string $providerEntity ): Message { return Message::query()->create([ - 'tenant_id' => $messageDTO->tenantId, 'external_identity_id' => $providerEntity, 'provider_message_id' => $messageDTO->providerMessageId, 'channel_id' => $messageDTO->channelId, diff --git a/app-modules/activity/src/Message/DTOs/NewMessageDTO.php b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php index 3fd773e40..11ce910dc 100644 --- a/app-modules/activity/src/Message/DTOs/NewMessageDTO.php +++ b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php @@ -10,7 +10,6 @@ final class NewMessageDTO { public function __construct( - public string $tenantId, public IdentityProvider $provider, public string $providerUsername, public string $externalAccountId, @@ -26,7 +25,6 @@ public function __construct( public static function make(array $payload): self { return new self( - tenantId: $payload['tenant_id'], provider: IdentityProvider::from($payload['provider']), providerUsername: $payload['provider_username'], externalAccountId: $payload['external_account_id'], diff --git a/app-modules/activity/src/Message/Models/MembershipEvent.php b/app-modules/activity/src/Message/Models/MembershipEvent.php index 48b427bc7..1f14d8915 100644 --- a/app-modules/activity/src/Message/Models/MembershipEvent.php +++ b/app-modules/activity/src/Message/Models/MembershipEvent.php @@ -7,7 +7,6 @@ use Carbon\CarbonInterface; use He4rt\Activity\Message\Enums\MembershipEventKind; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -15,7 +14,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $external_identity_id * @property string $kind * @property CarbonInterface $occurred_at @@ -37,14 +35,6 @@ public function identity(): BelongsTo return $this->belongsTo(ExternalIdentity::class, 'external_identity_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } - public function canonicalKind(): ?MembershipEventKind { return MembershipEventKind::tryFrom($this->kind); diff --git a/app-modules/activity/src/Message/Models/Message.php b/app-modules/activity/src/Message/Models/Message.php index b4a7e252c..487ca54b0 100644 --- a/app-modules/activity/src/Message/Models/Message.php +++ b/app-modules/activity/src/Message/Models/Message.php @@ -10,7 +10,6 @@ use He4rt\Activity\Message\Enums\MessageSourceKind; use He4rt\Activity\Reaction\Concerns\HasReactions; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -27,7 +26,6 @@ * @property string $content * @property int $obtained_experience * @property CarbonInterface|null $sent_at - * @property string $tenant_id * @property array|null $metadata * @property int $reactions_count * @property int $reactions_total @@ -59,14 +57,6 @@ public function provider(): BelongsTo return $this->belongsTo(ExternalIdentity::class, 'external_identity_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } - /** * @return BelongsTo */ diff --git a/app-modules/activity/src/Message/Models/MessageAttachment.php b/app-modules/activity/src/Message/Models/MessageAttachment.php index de2279efe..16cb45bcf 100644 --- a/app-modules/activity/src/Message/Models/MessageAttachment.php +++ b/app-modules/activity/src/Message/Models/MessageAttachment.php @@ -5,7 +5,6 @@ namespace He4rt\Activity\Message\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -13,7 +12,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $message_id * @property string|null $provider_attachment_id * @property string $url @@ -37,12 +35,4 @@ public function message(): BelongsTo { return $this->belongsTo(Message::class, 'message_id'); } - - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } } diff --git a/app-modules/activity/src/Message/Models/MessageEmbed.php b/app-modules/activity/src/Message/Models/MessageEmbed.php index 0c6e70df7..915bd0547 100644 --- a/app-modules/activity/src/Message/Models/MessageEmbed.php +++ b/app-modules/activity/src/Message/Models/MessageEmbed.php @@ -5,7 +5,6 @@ namespace He4rt\Activity\Message\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -13,7 +12,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $message_id * @property string|null $url * @property string|null $title @@ -39,14 +37,6 @@ public function message(): BelongsTo return $this->belongsTo(Message::class, 'message_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } - /** @return array */ protected function casts(): array { diff --git a/app-modules/activity/src/Message/Models/MessageMention.php b/app-modules/activity/src/Message/Models/MessageMention.php index 5e2910c27..d4723a001 100644 --- a/app-modules/activity/src/Message/Models/MessageMention.php +++ b/app-modules/activity/src/Message/Models/MessageMention.php @@ -6,7 +6,6 @@ use Carbon\CarbonInterface; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -14,7 +13,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $message_id * @property string|null $mentioned_identity_id * @property string $mentioned_provider_account_id @@ -43,12 +41,4 @@ public function mentionedIdentity(): BelongsTo { return $this->belongsTo(ExternalIdentity::class, 'mentioned_identity_id'); } - - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } } diff --git a/app-modules/activity/src/Message/Models/MessageThread.php b/app-modules/activity/src/Message/Models/MessageThread.php index ead322e40..82a7abd1b 100644 --- a/app-modules/activity/src/Message/Models/MessageThread.php +++ b/app-modules/activity/src/Message/Models/MessageThread.php @@ -5,7 +5,6 @@ namespace He4rt\Activity\Message\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -13,7 +12,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $message_id * @property string $provider_thread_id * @property string|null $name @@ -35,14 +33,6 @@ public function message(): BelongsTo return $this->belongsTo(Message::class, 'message_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } - /** @return array */ protected function casts(): array { diff --git a/app-modules/activity/src/Moderation/Models/ModerationEvent.php b/app-modules/activity/src/Moderation/Models/ModerationEvent.php index 950399fd4..8a58a10fb 100644 --- a/app-modules/activity/src/Moderation/Models/ModerationEvent.php +++ b/app-modules/activity/src/Moderation/Models/ModerationEvent.php @@ -9,7 +9,6 @@ use He4rt\Activity\Moderation\Enums\ModerationType; use He4rt\Activity\Timeline\Observers\ModerationEventObserver; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -18,7 +17,6 @@ /** * @property string $id - * @property string $tenant_id * @property string|null $external_identity_id * @property string|null $moderator_identity_id * @property ModerationType $type @@ -61,12 +59,6 @@ public function sourceMessage(): BelongsTo return $this->belongsTo(Message::class, 'source_message_id'); } - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return array */ protected function casts(): array { diff --git a/app-modules/activity/src/Reaction/Models/Reaction.php b/app-modules/activity/src/Reaction/Models/Reaction.php index 9c9fd5675..7be5b6d21 100644 --- a/app-modules/activity/src/Reaction/Models/Reaction.php +++ b/app-modules/activity/src/Reaction/Models/Reaction.php @@ -4,16 +4,13 @@ namespace He4rt\Activity\Reaction\Models; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property string $id - * @property string $tenant_id * @property string $reactable_type * @property string $reactable_id * @property string $emoji_key @@ -34,12 +31,6 @@ public function reactable(): MorphTo return $this->morphTo(); } - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - public function isCustomEmoji(): bool { return $this->emoji_id !== null; diff --git a/app-modules/activity/src/Timeline/Actions/CreatePost.php b/app-modules/activity/src/Timeline/Actions/CreatePost.php index 3ec0332a8..da219fcf4 100644 --- a/app-modules/activity/src/Timeline/Actions/CreatePost.php +++ b/app-modules/activity/src/Timeline/Actions/CreatePost.php @@ -29,7 +29,6 @@ public function handle(CreatePostDTO $dto): Timeline return Timeline::query()->create([ 'user_id' => $dto->userId, - 'tenant_id' => $dto->tenantId, 'postable_type' => $postEntry->getMorphClass(), 'postable_id' => $postEntry->id, ]); diff --git a/app-modules/activity/src/Timeline/Actions/CreateReply.php b/app-modules/activity/src/Timeline/Actions/CreateReply.php index da673c5d6..57f372d70 100644 --- a/app-modules/activity/src/Timeline/Actions/CreateReply.php +++ b/app-modules/activity/src/Timeline/Actions/CreateReply.php @@ -20,12 +20,11 @@ public function handle(CreateReplyDTO $dto): Timeline $parentTimeline = Timeline::query() ->where('id', $dto->parentTimelineId) - ->where('tenant_id', $dto->tenantId) ->firstOrFail(); $rootId = $parentTimeline->root_id ?? $parentTimeline->id; - return DB::transaction(static function () use ($dto, $content, $parentTimeline, $rootId): Timeline { + return DB::transaction(static function () use ($dto, $content, $rootId): Timeline { $postEntry = PostEntry::query()->create([ 'content' => $content, ]); @@ -36,7 +35,6 @@ public function handle(CreateReplyDTO $dto): Timeline return Timeline::query()->create([ 'user_id' => $dto->userId, - 'tenant_id' => $parentTimeline->tenant_id, 'postable_type' => $postEntry->getMorphClass(), 'postable_id' => $postEntry->id, 'root_id' => $rootId, diff --git a/app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php b/app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php index 78ce7f13b..74b63a1fd 100644 --- a/app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php +++ b/app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php @@ -29,7 +29,6 @@ public function handle(ModerationEvent $event): ?Timeline return Timeline::query()->create([ 'user_id' => $userId, - 'tenant_id' => $event->tenant_id, 'postable_type' => $event->getMorphClass(), 'postable_id' => $event->id, ]); diff --git a/app-modules/activity/src/Timeline/Actions/TogglePinPost.php b/app-modules/activity/src/Timeline/Actions/TogglePinPost.php index 61af6a6eb..d6b53beb6 100644 --- a/app-modules/activity/src/Timeline/Actions/TogglePinPost.php +++ b/app-modules/activity/src/Timeline/Actions/TogglePinPost.php @@ -25,7 +25,6 @@ public function handle(User $user, Timeline $timeline): void Timeline::withoutTimestamps(static function () use ($user, $timeline): void { Timeline::query() ->where('user_id', $user->id) - ->where('tenant_id', $timeline->tenant_id) ->where('pinned', operator: true) ->update(['pinned' => false]); diff --git a/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php b/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php index 2f385dca6..a1b53fe32 100644 --- a/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php +++ b/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php @@ -11,7 +11,6 @@ */ public function __construct( public string $userId, - public string $tenantId, public string $content, public array $images = [], ) {} diff --git a/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php b/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php index f585f5644..c82476033 100644 --- a/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php +++ b/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php @@ -11,7 +11,6 @@ */ public function __construct( public string $userId, - public string $tenantId, public string $parentTimelineId, public string $content, public array $images = [], diff --git a/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php b/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php index 8272786e7..f76418375 100644 --- a/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php +++ b/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php @@ -26,18 +26,11 @@ public function handle(ActionExecuted $event): void return; } - $tenantId = $action->tenant_id ?? $action->case?->tenant_id; - - if ($tenantId === null) { - return; - } - $case = $action->case; - $subjectIdentityId = $this->resolveIdentity($case?->author_id, $tenantId); - $moderatorIdentityId = $this->resolveIdentity($action->moderator_id, $tenantId); + $subjectIdentityId = $this->resolveIdentity($case?->author_id); + $moderatorIdentityId = $this->resolveIdentity($action->moderator_id); ModerationEvent::query()->create([ - 'tenant_id' => $tenantId, 'external_identity_id' => $subjectIdentityId, 'moderator_identity_id' => $moderatorIdentityId, 'type' => ModerationType::from($action->action_type->value), @@ -54,7 +47,7 @@ public function handle(ActionExecuted $event): void ]); } - private function resolveIdentity(?string $userId, string $tenantId): ?string + private function resolveIdentity(?string $userId): ?string { if ($userId === null) { return null; @@ -63,7 +56,6 @@ private function resolveIdentity(?string $userId, string $tenantId): ?string return ExternalIdentity::query() ->where('model_id', $userId) ->where('model_type', (new User)->getMorphClass()) - ->where('tenant_id', $tenantId) ->value('id'); } } diff --git a/app-modules/activity/src/Timeline/Queries/TimelineFeed.php b/app-modules/activity/src/Timeline/Queries/TimelineFeed.php index fdbd43483..cea5f3cf1 100644 --- a/app-modules/activity/src/Timeline/Queries/TimelineFeed.php +++ b/app-modules/activity/src/Timeline/Queries/TimelineFeed.php @@ -9,15 +9,10 @@ final readonly class TimelineFeed { - public function __construct( - private string $tenantId, - ) {} - /** @return Builder */ public function builder(): Builder { return Timeline::query() - ->where('tenant_id', $this->tenantId) ->where('is_ignored', operator: false) ->whereHas('user') ->whereNull('parent_id')->latest(); diff --git a/app-modules/activity/src/Timeline/Timeline.php b/app-modules/activity/src/Timeline/Timeline.php index 959ed2a2d..3340c17af 100644 --- a/app-modules/activity/src/Timeline/Timeline.php +++ b/app-modules/activity/src/Timeline/Timeline.php @@ -7,7 +7,6 @@ use Carbon\CarbonInterface; use He4rt\Activity\Database\Factories\TimelineFactory; use He4rt\Activity\Reaction\Concerns\HasReactions; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -20,7 +19,6 @@ /** * @property string $id * @property string $user_id - * @property string $tenant_id * @property string $postable_type * @property string $postable_id * @property string|null $root_id @@ -45,12 +43,6 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return MorphTo */ public function postable(): MorphTo { @@ -85,7 +77,6 @@ protected function casts(): array { return [ 'user_id' => 'string', - 'tenant_id' => 'string', 'root_id' => 'string', 'parent_id' => 'string', 'is_ignored' => 'boolean', diff --git a/app-modules/activity/src/Tracking/Actions/TrackActivity.php b/app-modules/activity/src/Tracking/Actions/TrackActivity.php index 52b39dbe8..14c9bba22 100644 --- a/app-modules/activity/src/Tracking/Actions/TrackActivity.php +++ b/app-modules/activity/src/Tracking/Actions/TrackActivity.php @@ -35,7 +35,6 @@ public function handle(TrackActivityDTO $dto): ?Interaction $interaction = Interaction::query()->create([ 'character_id' => $dto->characterId, - 'tenant_id' => $dto->tenantId, 'type' => $dto->type, 'provider' => $dto->provider, 'value_tier' => $classification['tier'], diff --git a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php index 05a5ec8db..0c73c77cd 100644 --- a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php +++ b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php @@ -12,7 +12,6 @@ { public function __construct( public string $characterId, - public string $tenantId, public ActivityType $type, public IdentityProvider $provider, public DateTimeImmutable $occurredAt, diff --git a/app-modules/activity/src/Tracking/Models/Interaction.php b/app-modules/activity/src/Tracking/Models/Interaction.php index 37c58c949..91ac563f9 100644 --- a/app-modules/activity/src/Tracking/Models/Interaction.php +++ b/app-modules/activity/src/Tracking/Models/Interaction.php @@ -11,7 +11,6 @@ use He4rt\Activity\Tracking\Enums\ValueTier; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -22,7 +21,6 @@ /** * @property string $id * @property string $character_id - * @property string $tenant_id * @property ActivityType $type * @property IdentityProvider $provider * @property ValueTier $value_tier @@ -53,14 +51,6 @@ public function character(): BelongsTo return $this->belongsTo(Character::class); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return MorphTo */ diff --git a/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php b/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php index 88c7846cd..997e13963 100644 --- a/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php +++ b/app-modules/activity/src/Voice/Actions/NewVoiceMessage.php @@ -25,7 +25,6 @@ public function persist(NewVoiceMessageDTO $voiceDTO): void try { DB::transaction(function () use ($voiceDTO): void { $userDto = ResolveUserProviderDTO::make([ - 'tenant_id' => $voiceDTO->tenantId, 'provider' => $voiceDTO->provider, 'external_account_id' => $voiceDTO->externalAccountId, 'model_type' => (new User)->getMorphClass(), @@ -40,7 +39,6 @@ public function persist(NewVoiceMessageDTO $voiceDTO): void ); Voice::query()->create([ - 'tenant_id' => $voiceDTO->tenantId, 'external_identity_id' => $userContext->provider->id, 'channel_name' => $voiceDTO->channelName, 'channel_id' => $voiceDTO->channelId, @@ -52,7 +50,6 @@ public function persist(NewVoiceMessageDTO $voiceDTO): void } catch (Throwable $throwable) { Log::channel('bot-discord')->error('NewVoiceMessage failed', [ 'external_account_id' => $voiceDTO->externalAccountId, - 'tenant_id' => $voiceDTO->tenantId, 'exception' => $throwable, ]); } diff --git a/app-modules/activity/src/Voice/Actions/RecordVoicePresence.php b/app-modules/activity/src/Voice/Actions/RecordVoicePresence.php index 2cd24c5a7..d98906c70 100644 --- a/app-modules/activity/src/Voice/Actions/RecordVoicePresence.php +++ b/app-modules/activity/src/Voice/Actions/RecordVoicePresence.php @@ -39,7 +39,6 @@ public function persistMany(array $dtos): void private function record(RecordVoicePresenceDTO $dto): void { $userDto = ResolveUserProviderDTO::make([ - 'tenant_id' => $dto->tenantId, 'provider' => $dto->provider, 'external_account_id' => $dto->externalAccountId, 'model_type' => (new User)->getMorphClass(), @@ -49,7 +48,6 @@ private function record(RecordVoicePresenceDTO $dto): void $userContext = resolve(ResolveUserContext::class)->handle($userDto); Voice::query()->create([ - 'tenant_id' => $dto->tenantId, 'external_identity_id' => $userContext->provider->id, 'channel_name' => $dto->channelName, 'channel_id' => $dto->channelId, diff --git a/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php b/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php index b78dd006d..fcbb9a46d 100644 --- a/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php +++ b/app-modules/activity/src/Voice/DTOs/NewVoiceMessageDTO.php @@ -10,7 +10,6 @@ final readonly class NewVoiceMessageDTO { public function __construct( - public string $tenantId, public IdentityProvider $provider, public string $externalAccountId, public VoiceStatesEnum $voiceState, diff --git a/app-modules/activity/src/Voice/DTOs/RecordVoicePresenceDTO.php b/app-modules/activity/src/Voice/DTOs/RecordVoicePresenceDTO.php index 3a09cdefc..e474b95a5 100644 --- a/app-modules/activity/src/Voice/DTOs/RecordVoicePresenceDTO.php +++ b/app-modules/activity/src/Voice/DTOs/RecordVoicePresenceDTO.php @@ -11,7 +11,6 @@ final readonly class RecordVoicePresenceDTO { public function __construct( - public string $tenantId, public IdentityProvider $provider, public string $externalAccountId, public VoicePresenceEnum $presence, @@ -23,7 +22,7 @@ public function __construct( /** * Fan a single voice-state change out into one DTO per transition. * - * The identity (tenant, provider, account, username) is shared across every + * The identity (provider, account, username) is shared across every * transition of the change; the presence and channel come from each * already-resolved VoiceTransition — a move emits both a `left` and a * `joined`. @@ -32,14 +31,12 @@ public function __construct( * @return list */ public static function makeMany( - string $tenantId, IdentityProvider $provider, string $externalAccountId, array $transitions, ?string $username = null, ): array { return array_map(fn (VoiceTransition $transition): self => new self( - tenantId: $tenantId, provider: $provider, externalAccountId: $externalAccountId, presence: $transition->presence, diff --git a/app-modules/activity/src/Voice/Models/Voice.php b/app-modules/activity/src/Voice/Models/Voice.php index 1329435fd..bcfbf0b40 100644 --- a/app-modules/activity/src/Voice/Models/Voice.php +++ b/app-modules/activity/src/Voice/Models/Voice.php @@ -16,7 +16,6 @@ /** * @property int $id * @property string $external_identity_id - * @property string $tenant_id * @property string|null $provider_message_id * @property string $channel_name * @property string|null $channel_id diff --git a/app-modules/activity/src/Voice/Queries/GetCurrentVoiceChannel.php b/app-modules/activity/src/Voice/Queries/GetCurrentVoiceChannel.php index e8f447e48..2ccb4a667 100644 --- a/app-modules/activity/src/Voice/Queries/GetCurrentVoiceChannel.php +++ b/app-modules/activity/src/Voice/Queries/GetCurrentVoiceChannel.php @@ -20,13 +20,12 @@ */ final readonly class GetCurrentVoiceChannel { - public function handle(string $tenantId, IdentityProvider $provider, string $externalAccountId): ?CurrentVoiceChannel + public function handle(IdentityProvider $provider, string $externalAccountId): ?CurrentVoiceChannel { $latest = Voice::query() ->join('external_identities', 'external_identities.id', '=', 'voice_messages.external_identity_id') ->where('external_identities.external_account_id', $externalAccountId) ->where('external_identities.provider', $provider->value) - ->where('voice_messages.tenant_id', $tenantId) ->orderByDesc('voice_messages.id') ->first(['voice_messages.state', 'voice_messages.channel_id', 'voice_messages.channel_name']); diff --git a/app-modules/activity/tests/Unit/Actions/NewMessageTest.php b/app-modules/activity/tests/Unit/Actions/NewMessageTest.php index 890fd0f21..7b953c8a0 100644 --- a/app-modules/activity/tests/Unit/Actions/NewMessageTest.php +++ b/app-modules/activity/tests/Unit/Actions/NewMessageTest.php @@ -9,22 +9,18 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('persist delegates to IncrementExperience and stores message', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(['is_donator' => false]); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 0]); $provider = ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -34,7 +30,6 @@ $content = str_repeat('a', 200); $dto = new NewMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, providerUsername: 'testuser', externalAccountId: '123456', @@ -57,15 +52,12 @@ }); test('supporter gets double xp on message', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(['is_donator' => true]); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 0]); ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -74,7 +66,6 @@ ]); $dto = new NewMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, providerUsername: 'supporter', externalAccountId: '789', @@ -91,15 +82,12 @@ }); test('short message gives minimum 1 xp at level 1', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(['is_donator' => false]); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 0]); ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -108,7 +96,6 @@ ]); $dto = new NewMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, providerUsername: 'newbie', externalAccountId: '456', diff --git a/app-modules/activity/tests/Unit/Actions/NewVoiceMessageTest.php b/app-modules/activity/tests/Unit/Actions/NewVoiceMessageTest.php index ede8fef46..697250f1f 100644 --- a/app-modules/activity/tests/Unit/Actions/NewVoiceMessageTest.php +++ b/app-modules/activity/tests/Unit/Actions/NewVoiceMessageTest.php @@ -9,22 +9,18 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('unmuted voice state awards multiplier times level', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 4_500]); // level 10 ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -33,7 +29,6 @@ ]); $dto = new NewVoiceMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '123456', voiceState: VoiceStatesEnum::Unmuted, @@ -53,20 +48,16 @@ ->and($voice->state)->toBe('unmuted') ->and($voice->channel_name)->toBe('general-voice') ->and($voice->channel_id)->toBe('111222333') - ->and($voice->tenant_id)->toBe($tenant->id) ->and($voice->occurred_at)->not->toBeNull(); }); test('muted voice state awards reduced xp', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 4_500]); // level 10 ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -75,7 +66,6 @@ ]); $dto = new NewVoiceMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '789', voiceState: VoiceStatesEnum::Muted, @@ -94,15 +84,12 @@ }); test('disabled voice state awards zero xp', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 4_500]); // level 10 ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -111,7 +98,6 @@ ]); $dto = new NewVoiceMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '456', voiceState: VoiceStatesEnum::Disabled, @@ -129,15 +115,12 @@ }); test('records channel name, channel id and occurred_at', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 0]); ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -146,7 +129,6 @@ ]); $dto = new NewVoiceMessageDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '321', voiceState: VoiceStatesEnum::Unmuted, diff --git a/app-modules/activity/tests/Unit/Actions/RecordVoicePresenceTest.php b/app-modules/activity/tests/Unit/Actions/RecordVoicePresenceTest.php index 3dbf66c78..9b4aae016 100644 --- a/app-modules/activity/tests/Unit/Actions/RecordVoicePresenceTest.php +++ b/app-modules/activity/tests/Unit/Actions/RecordVoicePresenceTest.php @@ -11,7 +11,6 @@ use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Actions\ResolveUserContext; use He4rt\Identity\User\Models\User; use He4rt\Identity\User\ValueObjects\UserContext; @@ -20,19 +19,16 @@ uses(RefreshDatabase::class); /** - * @return array{tenant: Tenant, character: Character, user: User, identity: ExternalIdentity} + * @return array{character: Character, user: User, identity: ExternalIdentity} */ function seedPresenceUser(string $externalAccountId): array { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $character = Character::factory() ->recycle($user) - ->recycle($tenant) ->create(['experience' => 4_500]); // level 10 $identity = ExternalIdentity::factory() - ->recycle($tenant) ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, @@ -40,13 +36,12 @@ function seedPresenceUser(string $externalAccountId): array 'external_account_id' => $externalAccountId, ]); - return ['tenant' => $tenant, 'character' => $character, 'user' => $user, 'identity' => $identity]; + return ['character' => $character, 'user' => $user, 'identity' => $identity]; } -function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presence, string $channelId): RecordVoicePresenceDTO +function presenceDto(string $account, VoicePresenceEnum $presence, string $channelId): RecordVoicePresenceDTO { return new RecordVoicePresenceDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: $account, presence: $presence, @@ -56,10 +51,9 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc } test('joined presence is recorded with zero xp and leaves character xp untouched', function (): void { - ['tenant' => $tenant, 'character' => $character] = seedPresenceUser('123456'); + ['character' => $character] = seedPresenceUser('123456'); resolve(RecordVoicePresence::class)->persistMany([new RecordVoicePresenceDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '123456', presence: VoicePresenceEnum::Joined, @@ -76,15 +70,13 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc ->and($voice->obtained_experience)->toBe(0) ->and($voice->channel_name)->toBe('general-voice') ->and($voice->channel_id)->toBe('111222333') - ->and($voice->tenant_id)->toBe($tenant->id) ->and($voice->occurred_at)->not->toBeNull(); }); test('left presence is recorded with zero xp', function (): void { - ['tenant' => $tenant] = seedPresenceUser('789'); + seedPresenceUser('789'); resolve(RecordVoicePresence::class)->persistMany([new RecordVoicePresenceDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '789', presence: VoicePresenceEnum::Left, @@ -100,10 +92,9 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc test('presence is audio-agnostic: a record is written regardless of mute or deaf state', function (): void { // The DTO carries no audio state, so a deafened user joining is recorded // exactly like anyone else — proven by the row simply existing. - ['tenant' => $tenant] = seedPresenceUser('456'); + seedPresenceUser('456'); resolve(RecordVoicePresence::class)->persistMany([new RecordVoicePresenceDTO( - tenantId: $tenant->id, provider: IdentityProvider::Discord, externalAccountId: '456', presence: VoicePresenceEnum::Joined, @@ -116,7 +107,6 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc test('makeMany fans the shared identity across every transition', function (): void { $dtos = RecordVoicePresenceDTO::makeMany( - tenantId: 'tenant-1', provider: IdentityProvider::Discord, externalAccountId: '777', transitions: [ @@ -135,15 +125,13 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc ->and($dtos[1]->channelName)->toBe('B'); foreach ($dtos as $dto) { - expect($dto->tenantId)->toBe('tenant-1') - ->and($dto->externalAccountId)->toBe('777') + expect($dto->externalAccountId)->toBe('777') ->and($dto->username)->toBe('mover'); } }); test('makeMany returns an empty list for no transitions', function (): void { expect(RecordVoicePresenceDTO::makeMany( - tenantId: 'tenant-1', provider: IdentityProvider::Discord, externalAccountId: '777', transitions: [], @@ -157,11 +145,11 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc }); test('persistMany records every transition of a move', function (): void { - ['tenant' => $tenant] = seedPresenceUser('777'); + seedPresenceUser('777'); resolve(RecordVoicePresence::class)->persistMany([ - presenceDto($tenant, '777', VoicePresenceEnum::Left, 'chA'), - presenceDto($tenant, '777', VoicePresenceEnum::Joined, 'chB'), + presenceDto('777', VoicePresenceEnum::Left, 'chA'), + presenceDto('777', VoicePresenceEnum::Joined, 'chB'), ]); expect(Voice::query()->count())->toBe(2) @@ -170,7 +158,7 @@ function presenceDto(Tenant $tenant, string $account, VoicePresenceEnum $presenc }); test('persistMany rolls back every row when one transition fails', function (): void { - ['tenant' => $tenant, 'character' => $character, 'user' => $user, 'identity' => $identity] = seedPresenceUser('555'); + ['character' => $character, 'user' => $user, 'identity' => $identity] = seedPresenceUser('555'); // First transition resolves fine (and inserts), the second blows up — the // whole move must roll back, leaving no half-written presence. ResolveUserContext @@ -196,8 +184,8 @@ public function handle(ResolveUserProviderDTO $dto): UserContext app()->instance(ResolveUserContext::class, $fake); expect(fn () => resolve(RecordVoicePresence::class)->persistMany([ - presenceDto($tenant, '555', VoicePresenceEnum::Left, 'chA'), - presenceDto($tenant, '555', VoicePresenceEnum::Joined, 'chB'), + presenceDto('555', VoicePresenceEnum::Left, 'chA'), + presenceDto('555', VoicePresenceEnum::Joined, 'chB'), ]))->toThrow(RuntimeException::class, 'boom'); expect(Voice::query()->count())->toBe(0); diff --git a/app-modules/activity/tests/Unit/Queries/GetCurrentVoiceChannelTest.php b/app-modules/activity/tests/Unit/Queries/GetCurrentVoiceChannelTest.php index efc2db9ba..3a60f4d09 100644 --- a/app-modules/activity/tests/Unit/Queries/GetCurrentVoiceChannelTest.php +++ b/app-modules/activity/tests/Unit/Queries/GetCurrentVoiceChannelTest.php @@ -7,33 +7,25 @@ use He4rt\Activity\Voice\ValueObjects\CurrentVoiceChannel; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -/** - * @return array{tenant: Tenant, identity: ExternalIdentity} - */ -function seedVoiceIdentity(string $externalAccountId = '123456'): array +function seedVoiceIdentity(string $externalAccountId = '123456'): ExternalIdentity { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $identity = ExternalIdentity::factory() - ->recycle($tenant) + return ExternalIdentity::factory() ->create([ 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'provider' => IdentityProvider::Discord, 'external_account_id' => $externalAccountId, ]); - - return ['tenant' => $tenant, 'identity' => $identity]; } -function recordVoice(Tenant $tenant, ExternalIdentity $identity, string $state, ?string $channelId, ?string $channelName = null): void +function recordVoice(ExternalIdentity $identity, string $state, ?string $channelId, ?string $channelName = null): void { $factory = match ($state) { 'joined' => Voice::factory()->joined(), @@ -42,7 +34,6 @@ function recordVoice(Tenant $tenant, ExternalIdentity $identity, string $state, }; $factory->create([ - 'tenant_id' => $tenant->id, 'external_identity_id' => $identity->id, 'channel_id' => $channelId, 'channel_name' => $channelName ?? 'channel', @@ -50,10 +41,9 @@ function recordVoice(Tenant $tenant, ExternalIdentity $identity, string $state, } test('returns null when the user has no voice rows', function (): void { - ['tenant' => $tenant] = seedVoiceIdentity(); + seedVoiceIdentity(); $current = resolve(GetCurrentVoiceChannel::class)->handle( - $tenant->id, IdentityProvider::Discord, '123456', ); @@ -62,10 +52,10 @@ function recordVoice(Tenant $tenant, ExternalIdentity $identity, string $state, }); test('returns the channel when the latest row is a join', function (): void { - ['tenant' => $tenant, 'identity' => $identity] = seedVoiceIdentity(); - recordVoice($tenant, $identity, 'joined', 'chA', 'General'); + $identity = seedVoiceIdentity(); + recordVoice($identity, 'joined', 'chA', 'General'); - $current = resolve(GetCurrentVoiceChannel::class)->handle($tenant->id, IdentityProvider::Discord, '123456'); + $current = resolve(GetCurrentVoiceChannel::class)->handle(IdentityProvider::Discord, '123456'); expect($current)->toBeInstanceOf(CurrentVoiceChannel::class) ->and($current->channelId)->toBe('chA') @@ -73,42 +63,42 @@ function recordVoice(Tenant $tenant, ExternalIdentity $identity, string $state, }); test('returns null when the latest row is a leave', function (): void { - ['tenant' => $tenant, 'identity' => $identity] = seedVoiceIdentity(); - recordVoice($tenant, $identity, 'joined', 'chA'); - recordVoice($tenant, $identity, 'left', 'chA'); + $identity = seedVoiceIdentity(); + recordVoice($identity, 'joined', 'chA'); + recordVoice($identity, 'left', 'chA'); - $current = resolve(GetCurrentVoiceChannel::class)->handle($tenant->id, IdentityProvider::Discord, '123456'); + $current = resolve(GetCurrentVoiceChannel::class)->handle(IdentityProvider::Discord, '123456'); expect($current)->toBeNull(); }); test('treats an xp ticker row (unmuted) as present in that channel', function (): void { - ['tenant' => $tenant, 'identity' => $identity] = seedVoiceIdentity(); - recordVoice($tenant, $identity, 'joined', 'chA'); - recordVoice($tenant, $identity, 'unmuted', 'chA', 'General'); + $identity = seedVoiceIdentity(); + recordVoice($identity, 'joined', 'chA'); + recordVoice($identity, 'unmuted', 'chA', 'General'); - $current = resolve(GetCurrentVoiceChannel::class)->handle($tenant->id, IdentityProvider::Discord, '123456'); + $current = resolve(GetCurrentVoiceChannel::class)->handle(IdentityProvider::Discord, '123456'); expect($current?->channelId)->toBe('chA'); }); test('follows a move to the most recent channel', function (): void { - ['tenant' => $tenant, 'identity' => $identity] = seedVoiceIdentity(); - recordVoice($tenant, $identity, 'joined', 'chA'); - recordVoice($tenant, $identity, 'left', 'chA'); - recordVoice($tenant, $identity, 'joined', 'chB', 'Dev'); + $identity = seedVoiceIdentity(); + recordVoice($identity, 'joined', 'chA'); + recordVoice($identity, 'left', 'chA'); + recordVoice($identity, 'joined', 'chB', 'Dev'); - $current = resolve(GetCurrentVoiceChannel::class)->handle($tenant->id, IdentityProvider::Discord, '123456'); + $current = resolve(GetCurrentVoiceChannel::class)->handle(IdentityProvider::Discord, '123456'); expect($current?->channelId)->toBe('chB') ->and($current?->channelName)->toBe('Dev'); }); test('is scoped to the requested user', function (): void { - ['tenant' => $tenant, 'identity' => $identity] = seedVoiceIdentity('aaa'); - recordVoice($tenant, $identity, 'joined', 'chA'); + $identity = seedVoiceIdentity('aaa'); + recordVoice($identity, 'joined', 'chA'); - $current = resolve(GetCurrentVoiceChannel::class)->handle($tenant->id, IdentityProvider::Discord, 'bbb'); + $current = resolve(GetCurrentVoiceChannel::class)->handle(IdentityProvider::Discord, 'bbb'); expect($current)->toBeNull(); }); diff --git a/app-modules/activity/tests/Unit/Timeline/CreatePostTest.php b/app-modules/activity/tests/Unit/Timeline/CreatePostTest.php index b063a7138..f3bc8f061 100644 --- a/app-modules/activity/tests/Unit/Timeline/CreatePostTest.php +++ b/app-modules/activity/tests/Unit/Timeline/CreatePostTest.php @@ -6,7 +6,6 @@ use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\DTOs\CreatePostDTO; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -14,20 +13,17 @@ uses(RefreshDatabase::class); beforeEach(function (): void { - $this->tenant = Tenant::factory()->create(); $this->user = User::factory()->create(); }); test('creates a post entry and timeline record', function (): void { $timeline = resolve(CreatePost::class)->handle(new CreatePostDTO( userId: $this->user->id, - tenantId: $this->tenant->id, content: 'Hello **He4rt** community!', )); expect($timeline)->toBeInstanceOf(Timeline::class) ->and($timeline->user_id)->toBe($this->user->id) - ->and($timeline->tenant_id)->toBe($this->tenant->id) ->and($timeline->postable_type)->toBe((new PostEntry)->getMorphClass()) ->and($timeline->postable)->toBeInstanceOf(PostEntry::class) ->and($timeline->postable->content)->toBe('Hello **He4rt** community!') @@ -43,7 +39,6 @@ $timeline = resolve(CreatePost::class)->handle(new CreatePostDTO( userId: $this->user->id, - tenantId: $this->tenant->id, content: $longContent, )); @@ -53,7 +48,6 @@ test('post creation is atomic — no orphaned PostEntry on Timeline failure', function (): void { expect(fn () => resolve(CreatePost::class)->handle(new CreatePostDTO( userId: 'not-a-valid-uuid', - tenantId: $this->tenant->id, content: 'This should fail', )))->toThrow(QueryException::class); @@ -63,7 +57,6 @@ test('creates a post with empty content is rejected', function (): void { resolve(CreatePost::class)->handle(new CreatePostDTO( userId: $this->user->id, - tenantId: $this->tenant->id, content: '', )); })->throws(InvalidArgumentException::class); diff --git a/app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php b/app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php index d0fadbf7a..c8f75abe2 100644 --- a/app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php +++ b/app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php @@ -6,7 +6,6 @@ use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\DTOs\CreateReplyDTO; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -14,14 +13,12 @@ uses(RefreshDatabase::class); test('creates a reply to a root post', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $rootPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); @@ -30,26 +27,22 @@ $reply = resolve(CreateReply::class)->handle(new CreateReplyDTO( userId: $replier->id, - tenantId: $tenant->id, parentTimelineId: $rootPost->id, content: 'Great post!', )); expect($reply->parent_id)->toBe($rootPost->id) ->and($reply->root_id)->toBe($rootPost->id) - ->and($reply->tenant_id)->toBe($tenant->id) ->and($reply->postable->content)->toBe('Great post!'); }); test('reply to a reply flattens to root level', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $rootPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); @@ -58,7 +51,6 @@ $reply = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $replyEntry->id, 'root_id' => $rootPost->id, @@ -69,7 +61,6 @@ $replyToReply = resolve(CreateReply::class)->handle(new CreateReplyDTO( userId: $secondReplier->id, - tenantId: $tenant->id, parentTimelineId: $reply->id, content: 'Replying to a reply', )); @@ -79,14 +70,12 @@ }); test('reply creation is atomic — no orphaned PostEntry on Timeline failure', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $rootPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); @@ -95,7 +84,6 @@ expect(fn () => resolve(CreateReply::class)->handle(new CreateReplyDTO( userId: 'non-existent-user-id', - tenantId: $tenant->id, parentTimelineId: $rootPost->id, content: 'This should fail', )))->toThrow(QueryException::class); diff --git a/app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php b/app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php index 587ca9285..f1c427468 100644 --- a/app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php +++ b/app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php @@ -5,7 +5,6 @@ use He4rt\Activity\Timeline\Actions\DeleteReply; use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -13,14 +12,12 @@ uses(RefreshDatabase::class); test('owner can delete their own reply', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $rootEntry = PostEntry::factory()->create(); $rootPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $rootEntry->id, ]); @@ -30,7 +27,6 @@ $reply = Timeline::factory() ->for($replier) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $replyEntry->id, 'root_id' => $rootPost->id, @@ -44,14 +40,12 @@ }); test('cannot delete another users reply', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $rootEntry = PostEntry::factory()->create(); $rootPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $rootEntry->id, ]); @@ -61,7 +55,6 @@ $reply = Timeline::factory() ->for($replier) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $replyEntry->id, 'root_id' => $rootPost->id, @@ -74,14 +67,12 @@ })->throws(AuthorizationException::class); test('cannot delete a root post via delete reply', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $rootEntry = PostEntry::factory()->create(); $rootPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $rootEntry->id, ]); diff --git a/app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php b/app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php index 056195d5c..80541e5cf 100644 --- a/app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php +++ b/app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php @@ -6,23 +6,19 @@ use He4rt\Activity\Moderation\Models\ModerationEvent; use He4rt\Activity\Timeline\Timeline; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('publishes a timeline entry for a ban moderation event via observer', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $moderatorIdentity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_id' => $user->id, 'model_type' => (new User)->getMorphClass(), ]); $event = ModerationEvent::query()->create([ - 'tenant_id' => $tenant->id, 'external_identity_id' => null, 'moderator_identity_id' => $moderatorIdentity->id, 'type' => ModerationType::Ban, @@ -36,29 +32,24 @@ expect($timeline)->not->toBeNull() ->and($timeline->user_id)->toBe($user->id) - ->and($timeline->tenant_id)->toBe($tenant->id) ->and($timeline->postable_type)->toBe((new ModerationEvent)->getMorphClass()) ->and($timeline->postable_id)->toBe($event->id); }); test('publishes a timeline entry for a kick moderation event via observer', function (): void { - $tenant = Tenant::factory()->create(); $moderator = User::factory()->create(); $moderatorIdentity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_id' => $moderator->id, 'model_type' => (new User)->getMorphClass(), ]); $subject = User::factory()->create(); $subjectIdentity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_id' => $subject->id, 'model_type' => (new User)->getMorphClass(), ]); $event = ModerationEvent::query()->create([ - 'tenant_id' => $tenant->id, 'external_identity_id' => $subjectIdentity->id, 'moderator_identity_id' => $moderatorIdentity->id, 'type' => ModerationType::Kick, @@ -76,16 +67,13 @@ }); test('does not publish a timeline entry when moderator identity is missing', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $subjectIdentity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_id' => $user->id, 'model_type' => (new User)->getMorphClass(), ]); ModerationEvent::query()->create([ - 'tenant_id' => $tenant->id, 'external_identity_id' => $subjectIdentity->id, 'moderator_identity_id' => null, 'type' => ModerationType::Kick, @@ -97,10 +85,7 @@ }); test('does not publish a timeline entry for a warn moderation event', function (): void { - $tenant = Tenant::factory()->create(); - ModerationEvent::query()->create([ - 'tenant_id' => $tenant->id, 'external_identity_id' => null, 'moderator_identity_id' => null, 'type' => ModerationType::Warn, diff --git a/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php b/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php index 65f60a4a3..015afac24 100644 --- a/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php +++ b/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php @@ -5,128 +5,93 @@ use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Queries\TimelineFeed; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('returns only root posts for tenant ordered by newest first', function (): void { - $tenant = Tenant::factory()->create(); +test('returns only root posts ordered by newest first', function (): void { $user = User::factory()->create(); $older = Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, 'created_at' => now()->subHour(), ]); $newer = Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, 'created_at' => now(), ]); - $result = new TimelineFeed($tenant->id)->builder()->simplePaginate(15); + $result = (new TimelineFeed)->builder()->simplePaginate(15); expect($result)->toHaveCount(2) ->and($result->first()->id)->toBe($newer->id); }); test('excludes replies from feed', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $root = Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); $replyEntry = PostEntry::factory()->create(); Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $replyEntry->id, 'root_id' => $root->id, 'parent_id' => $root->id, ]); - $result = new TimelineFeed($tenant->id)->builder()->simplePaginate(15); + $result = (new TimelineFeed)->builder()->simplePaginate(15); expect($result)->toHaveCount(1) ->and($result->first()->id)->toBe($root->id); }); test('excludes ignored posts from feed', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, 'is_ignored' => true, ]); $visible = Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, ]); - $result = new TimelineFeed($tenant->id)->builder()->simplePaginate(15); + $result = (new TimelineFeed)->builder()->simplePaginate(15); expect($result)->toHaveCount(1) ->and($result->first()->id)->toBe($visible->id); }); test('excludes posts whose author no longer exists', function (): void { - $tenant = Tenant::factory()->create(); $author = User::factory()->create(); $ghost = User::factory()->create(); $visible = Timeline::factory()->for($author)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, ]); Timeline::factory()->for($ghost)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, ]); $ghost->delete(); - $result = new TimelineFeed($tenant->id)->builder()->simplePaginate(15); + $result = (new TimelineFeed)->builder()->simplePaginate(15); expect($result)->toHaveCount(1) ->and($result->first()->id)->toBe($visible->id); }); - -test('does not show posts from other tenants', function (): void { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $user = User::factory()->create(); - - Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenantA->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => PostEntry::factory()->create()->id, - ]); - - Timeline::factory()->for($user)->create([ - 'tenant_id' => $tenantB->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => PostEntry::factory()->create()->id, - ]); - - $result = new TimelineFeed($tenantA->id)->builder()->simplePaginate(15); - - expect($result)->toHaveCount(1); -}); diff --git a/app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php b/app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php index e438c1b9d..61941aa1e 100644 --- a/app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php +++ b/app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php @@ -4,32 +4,27 @@ use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('timeline belongs to user and tenant', function (): void { - $tenant = Tenant::factory()->create(); +test('timeline belongs to user', function (): void { $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $timeline = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); expect($timeline->user->id)->toBe($user->id) - ->and($timeline->tenant->id)->toBe($tenant->id) ->and($timeline->postable)->toBeInstanceOf(PostEntry::class); }); test('timeline has children and parent for threading', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $parentEntry = PostEntry::factory()->create(); $childEntry = PostEntry::factory()->create(); @@ -37,7 +32,6 @@ $parent = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $parentEntry->id, ]); @@ -45,7 +39,6 @@ $child = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $childEntry->id, 'root_id' => $parent->id, @@ -59,20 +52,17 @@ }); test('timeline has reactions via HasReactions trait', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $timeline = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); $timeline->reactions()->create([ - 'tenant_id' => $tenant->id, 'emoji_key' => '❤️', 'emoji_name' => 'heart', 'count' => 1, diff --git a/app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php b/app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php index 77b6351c5..52563842f 100644 --- a/app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php +++ b/app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php @@ -5,7 +5,6 @@ use He4rt\Activity\Timeline\Actions\TogglePinPost; use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -13,14 +12,12 @@ uses(RefreshDatabase::class); test('user can pin their own post', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $timeline = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); @@ -31,14 +28,12 @@ }); test('pinning a post unpins the previously pinned post', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $firstEntry = PostEntry::factory()->create(); $firstPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $firstEntry->id, 'pinned' => true, @@ -48,7 +43,6 @@ $secondPost = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $secondEntry->id, ]); @@ -60,14 +54,12 @@ }); test('user can unpin their own post', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $postEntry = PostEntry::factory()->create(); $timeline = Timeline::factory() ->for($user) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, 'pinned' => true, @@ -79,7 +71,6 @@ }); test('user cannot pin another users post', function (): void { - $tenant = Tenant::factory()->create(); $owner = User::factory()->create(); $other = User::factory()->create(); $postEntry = PostEntry::factory()->create(); @@ -87,7 +78,6 @@ $timeline = Timeline::factory() ->for($owner) ->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); diff --git a/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php index e13de21fe..dad6d8ec4 100644 --- a/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php +++ b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php @@ -7,7 +7,6 @@ use He4rt\Activity\Tracking\Events\InteractionApproved; use He4rt\Activity\Tracking\Models\Interaction; use He4rt\Gamification\Character\Models\Character; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -17,14 +16,12 @@ test('approves interaction and credits economy', function (): void { Event::fake([InteractionApproved::class]); - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $character = Character::factory()->recycle($user)->recycle($tenant)->create(['experience' => 500]); + $character = Character::factory()->recycle($user)->create(['experience' => 500]); $interaction = Interaction::factory() ->withEngagement(reactions: 42, comments: 12, bookmarks: 8) ->recycle($character) - ->recycle($tenant) ->create([ 'coins_min' => 100, 'coins_max' => 300, diff --git a/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php index 181f8f6bf..dec7d1e56 100644 --- a/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php +++ b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php @@ -11,7 +11,6 @@ use He4rt\Activity\Tracking\Events\InteractionTracked; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -21,13 +20,11 @@ test('tracks high tier activity as pending without crediting economy', function (): void { Event::fake([InteractionTracked::class]); - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + $character = Character::factory()->recycle($user)->create(); $dto = new TrackActivityDTO( characterId: $character->id, - tenantId: $tenant->id, type: ActivityType::Article, provider: IdentityProvider::DevTo, occurredAt: CarbonImmutable::now(), @@ -49,13 +46,11 @@ 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(); + $character = Character::factory()->recycle($user)->create(); $dto = new TrackActivityDTO( characterId: $character->id, - tenantId: $tenant->id, type: ActivityType::Engagement, provider: IdentityProvider::DevTo, occurredAt: CarbonImmutable::now(), @@ -76,13 +71,11 @@ }); test('deduplicates by external ref', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + $character = Character::factory()->recycle($user)->create(); $dto = new TrackActivityDTO( characterId: $character->id, - tenantId: $tenant->id, type: ActivityType::Article, provider: IdentityProvider::DevTo, occurredAt: CarbonImmutable::now(), diff --git a/app-modules/bot-discord/CONTEXT.md b/app-modules/bot-discord/CONTEXT.md index de05e8608..9718cec06 100644 --- a/app-modules/bot-discord/CONTEXT.md +++ b/app-modules/bot-discord/CONTEXT.md @@ -4,13 +4,16 @@ Discord bot runtime powered by Laracord. Handles websocket events, slash command ## Glossary -| Term | Definition | Not to be confused with | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | -| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | -| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | -| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | -| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| Term | Definition | Not to be confused with | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | +| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | +| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | +| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | +| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| **Sala Empresarial** | A `/sala`-created voice room that a partner-company member privatized via `/sala-empresarial`: `@everyone` is denied view-channel/connect/speak/use-voice-activity/send-messages/read-history while one Partner Role is allowed. Fully hidden from non-members — they cannot see the room nor who is inside. | A normal `/sala` room (public, permissions inherited from the category) | +| **Empresa Parceira** | An external company in a partnership with He4rt whose members hold a dedicated Discord role. Registered in `bot-discord.roles.partners` (`slug => role_id`); the system supports many, not just one. | A Squad (internal community group, owned by the `squads` module) | +| **Partner Role** | The Discord role that identifies the members of one Empresa Parceira (e.g. `brd`). It is the `allow` target stamped on a room when it becomes a Sala Empresarial; a caller must hold the role of the company they select. | Moderation admin/mod roles (`he4rt.discord.moderation.*`) | ## Module Boundaries diff --git a/app-modules/bot-discord/config/bot-discord.php b/app-modules/bot-discord/config/bot-discord.php index 1bab22eb4..6f0931081 100644 --- a/app-modules/bot-discord/config/bot-discord.php +++ b/app-modules/bot-discord/config/bot-discord.php @@ -3,14 +3,25 @@ declare(strict_types=1); return [ + 'guild_id' => env('HE4RT_DISCORD_GUILD_ID'), 'channels' => [ 'auto-report' => env('HE4RT_AUTO_REPORT_CHANNEL_ID', '1045804587195576451'), 'presentations' => env('HE4RT_PRESENTATIONS_CHANNEL_ID', '540993663468306433'), + 'general' => env('HE4RT_GERAL_CHANNEL_ID', '541443469642563585'), 'he4rt_delas' => env('HE4RT_DELAS_CHANNEL_ID', '1450492362416586845'), ], 'roles' => [ 'presentation' => env('HE4RT_PRESENTATION_ROLE_ID', '546150872397119491'), 'comite_delas' => env('HE4RT_COMITE_DELAS_ROLE_ID', '1014311739397001288'), 'he4rt_delas' => env('HE4RT_DELAS_ROLE_ID', '1018009963903328308'), + + /* + * Empresa Parceira registry: partner-company slug => Discord Partner Role id. + * Each entry enables `/sala-empresarial empresa:` for that company. + * Onboarding a new partner is one env + one line here — no code change. + */ + 'partners' => [ + 'brd' => env('HE4RT_PARTNER_BRD_ROLE_ID', '1522599722370465904'), + ], ], ]; diff --git a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md new file mode 100644 index 000000000..44395d51d --- /dev/null +++ b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md @@ -0,0 +1,51 @@ +--- +type: adr +title: 'Sala Empresarial — role-gated private voice rooms' +module: bot-discord +status: accepted +date: 2026-07-23 +--- + +# Sala Empresarial — role-gated private voice rooms + +Partner companies (**Empresa Parceira**) need private voice rooms on the guild. We add a +separate `/sala-empresarial empresa:` slash command that **converts an existing +`/sala`-tracked room** into a private one by stamping Discord permission overwrites: `deny` +`@everyone` and `allow` the selected company's **Partner Role** for `VIEW_CHANNEL`, +`CONNECT`, `SPEAK`, `USE_VAD`, `SEND_MESSAGES` and `READ_MESSAGE_HISTORY` (the last two lock +the room's built-in text chat). `MENTION_EVERYONE` is left unchanged by the command — it +keeps whatever the category inherits (every `/sala` room already blocks it). Because +`VIEW_CHANNEL` is denied to `@everyone`, the room is **fully hidden**: non-members cannot see +it in the channel list nor who is connected. + +## Considered Options + +- **Convert existing room vs. a flag on `/sala`.** We convert. `/sala` is shared by the + whole guild; branching its logic and gating a `empresarial:true` flag by role would + couple a general command to partner concerns. A dedicated command mirrors `/sala-limite` + and keeps `/sala` untouched. +- **Explicit `empresa` parameter vs. inferring the caller's role.** Explicit parameter. A + caller can belong to more than one Empresa Parceira, so intersecting their roles with the + registry is ambiguous. The parameter picks exactly one company; we then validate the + caller actually holds that company's Partner Role and reject (ephemeral) otherwise. +- **Config registry vs. hardcoding `brd`.** A flat `bot-discord.roles.partners` + (`slug => role_id`) registry. Adding a second partner company is one env + one config + line, no code change. The slash-command `choices` are built from this map. Each partner's + Partner Role id is seeded as an env-var default in `config/bot-discord.php` (the source of + truth), so no role ids live in this ADR. +- **Stateless vs. persisting the empresarial state.** Stateless. We only convert + `/sala`-tracked rooms, which `DynamicVoiceTask` auto-deletes when empty, so the overwrites + die with the channel. `VoiceChannelDTO` is left unchanged and Discord's overwrites are the + single source of truth; re-running the command is a harmless re-stamp. There is no revert + command — a room un-privatizes by emptying and being deleted. + +## Consequences + +- This is the first permission-overwrite code in `bot-discord`; prior room commands only + set the channel's parent/user-limit and relied on category inheritance. +- Gating is by Partner Role membership, **not** room ownership (unlike `/sala-limite`) — any + member of the selected company may privatize the room. +- Because `VIEW_CHANNEL` is denied to `@everyone`, empresarial rooms disappear from the + channel list for non-members and their occupant list is hidden. Discord force-disconnects + any non-member connected at conversion time (losing view access ejects them from a voice + channel) — accepted, and consistent with a fully private room. diff --git a/app-modules/bot-discord/src/Actions/ResolveDiscordTenant.php b/app-modules/bot-discord/src/Actions/ResolveDiscordTenant.php deleted file mode 100644 index 042ff56f5..000000000 --- a/app-modules/bot-discord/src/Actions/ResolveDiscordTenant.php +++ /dev/null @@ -1,21 +0,0 @@ -where('provider', IdentityProvider::Discord) - ->where('model_type', (new Tenant)->getMorphClass()) - ->where('external_account_id', $guildId) - ->firstOrFail(); - } -} diff --git a/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php b/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php new file mode 100644 index 000000000..b851f9263 --- /dev/null +++ b/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php @@ -0,0 +1,72 @@ + $callerRoleIds Discord role ids held by the caller. + * @param list $activeChannels The `/sala`-tracked rooms cache. + */ + public function execute( + string $companySlug, + array $callerRoleIds, + ?string $currentChannelId, + array $activeChannels, + ): EmpresarialRoomDecision { + if (!$this->isInsideTrackedRoom($currentChannelId, $activeChannels)) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::NotInTrackedRoom); + } + + $partnerRoleId = $this->resolvePartnerRoleId($companySlug); + + if ($partnerRoleId === null) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::UnknownCompany); + } + + if (!in_array($partnerRoleId, $callerRoleIds, strict: true)) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::MissingPartnerRole); + } + + return EmpresarialRoomDecision::approve( + EmpresarialOverwritePlan::for($companySlug, $partnerRoleId), + ); + } + + /** + * @param list $activeChannels + */ + private function isInsideTrackedRoom(?string $currentChannelId, array $activeChannels): bool + { + if ($currentChannelId === null) { + return false; + } + + return array_any($activeChannels, fn (VoiceChannelDTO $channel): bool => $channel->channelId === $currentChannelId); + } + + private function resolvePartnerRoleId(string $companySlug): ?string + { + /** @var array $partners */ + $partners = config('bot-discord.roles.partners', []); + + if (!array_key_exists($companySlug, $partners)) { + return null; + } + + return $partners[$companySlug]; + } +} diff --git a/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php new file mode 100644 index 000000000..12b3c1750 --- /dev/null +++ b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php @@ -0,0 +1,54 @@ + + */ + public const array PRIVATE_ROOM_PERMISSIONS = [ + 'view_channel', + 'connect', + 'speak', + 'use_vad', + 'send_messages', + 'read_message_history', + ]; + + /** + * @param list $denyEveryone Permissions denied to `@everyone`. + * @param list $allowPartnerRole Permissions allowed for the Partner Role. + */ + private function __construct( + public string $companySlug, + public string $partnerRoleId, + public array $denyEveryone, + public array $allowPartnerRole, + ) {} + + public static function for(string $companySlug, string $partnerRoleId): self + { + return new self( + companySlug: $companySlug, + partnerRoleId: $partnerRoleId, + denyEveryone: self::PRIVATE_ROOM_PERMISSIONS, + allowPartnerRole: self::PRIVATE_ROOM_PERMISSIONS, + ); + } +} diff --git a/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php b/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php new file mode 100644 index 000000000..1894b304f --- /dev/null +++ b/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php @@ -0,0 +1,34 @@ +plan instanceof EmpresarialOverwritePlan; + } +} diff --git a/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php b/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php new file mode 100644 index 000000000..2b1a987ed --- /dev/null +++ b/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php @@ -0,0 +1,27 @@ + '❌ Você precisa estar dentro de uma sala criada com /sala para usar este comando!', + self::MissingPartnerRole => '❌ Você não faz parte da empresa selecionada e não pode torná-la privada.', + self::UnknownCompany => '❌ Empresa parceira não encontrada.', + }; + } +} diff --git a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php index 211f12934..d244e4b62 100644 --- a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php +++ b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php @@ -8,7 +8,6 @@ use Discord\WebSockets\Event as Events; use He4rt\Activity\Message\Actions\NewMessage; use He4rt\Activity\Message\DTOs\NewMessageDTO; -use He4rt\BotDiscord\Actions\ResolveDiscordTenant; use He4rt\BotDiscord\Moderation\DiscordModerationAdapter; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Moderation\Enums\CaseSource; @@ -33,11 +32,8 @@ public function handle(Message $message): void } try { - $tenantProvider = resolve(ResolveDiscordTenant::class)->handle((string) $message->guild_id); - // Activity tracking — records message for XP/gamification regardless of moderation outcome. resolve(NewMessage::class)->persist(new NewMessageDTO( - tenantId: $tenantProvider->tenant_id, provider: IdentityProvider::Discord, providerUsername: $message->author->username.'#'.$message->author->discriminator, externalAccountId: $message->user_id, @@ -57,7 +53,6 @@ public function handle(Message $message): void 'guild_id' => (string) $message->guild_id, 'username' => $message->author->username, 'attachments' => [], - 'tenant_id' => (string) $tenantProvider->tenant_id, ]); resolve(SubmitForModeration::class)->execute($content, CaseSource::AutoDetect); diff --git a/app-modules/bot-discord/src/Events/VoicePresenceEvent.php b/app-modules/bot-discord/src/Events/VoicePresenceEvent.php index 36271c162..4c18c3057 100644 --- a/app-modules/bot-discord/src/Events/VoicePresenceEvent.php +++ b/app-modules/bot-discord/src/Events/VoicePresenceEvent.php @@ -10,7 +10,6 @@ use He4rt\Activity\Voice\Actions\RecordVoicePresence; use He4rt\Activity\Voice\DTOs\RecordVoicePresenceDTO; use He4rt\Activity\Voice\Queries\GetCurrentVoiceChannel; -use He4rt\BotDiscord\Actions\ResolveDiscordTenant; use He4rt\BotDiscord\Actions\VoiceTransitionResolver; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Support\Facades\Log; @@ -41,10 +40,7 @@ public function handle(VoiceStateUpdate $state, Discord $discord, ?VoiceStateUpd } try { - $tenant = resolve(ResolveDiscordTenant::class)->handle((string) $state->guild_id); - $current = resolve(GetCurrentVoiceChannel::class)->handle( - $tenant->tenant_id, IdentityProvider::Discord, (string) $state->user_id, ); @@ -66,7 +62,6 @@ public function handle(VoiceStateUpdate $state, Discord $discord, ?VoiceStateUpd // A move emits left+joined; persist them together so the log stays paired. resolve(RecordVoicePresence::class)->persistMany( RecordVoicePresenceDTO::makeMany( - tenantId: $tenant->tenant_id, provider: IdentityProvider::Discord, externalAccountId: (string) $state->user_id, transitions: $transitions, diff --git a/app-modules/bot-discord/src/Events/WelcomeMember.php b/app-modules/bot-discord/src/Events/WelcomeMember.php index b39cff9f2..9514be585 100644 --- a/app-modules/bot-discord/src/Events/WelcomeMember.php +++ b/app-modules/bot-discord/src/Events/WelcomeMember.php @@ -7,8 +7,8 @@ use Discord\Discord; use Discord\Parts\User\Member; use Discord\WebSockets\Event as Events; -use He4rt\BotDiscord\Actions\ResolveDiscordTenant; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\User\Actions\ResolveUserContext; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Log; @@ -23,18 +23,33 @@ class WelcomeMember extends Event { protected $handler = Events::GUILD_MEMBER_ADD; + /** + * Build the Discord CDN URL for a guild icon hash. Returns null when the + * guild has no icon; animated hashes (`a_…`) resolve to `.gif`. + */ + public static function guildIconUrl(string $guildId, ?string $iconHash): ?string + { + if (blank($guildId) || blank($iconHash)) { + return null; + } + + $extension = str_starts_with($iconHash, 'a_') ? 'gif' : 'png'; + + return sprintf( + 'https://cdn.discordapp.com/icons/%s/%s.%s', + $guildId, + $iconHash, + $extension + ); + } + public function handle(Member $member, Discord $discord): void { $channelId = config('bot-discord.channels.auto-report'); - $tenantProvider = null; - try { - $tenantProvider = resolve(ResolveDiscordTenant::class)->handle((string) $member->guild_id); - $userDto = ResolveUserProviderDTO::make([ - 'tenant_id' => $tenantProvider->tenant_id, - 'provider' => $tenantProvider->provider, + 'provider' => IdentityProvider::Discord, 'external_account_id' => $member->user->id, 'model_type' => (new User)->getMorphClass(), 'username' => $member->user->username, @@ -44,8 +59,7 @@ public function handle(Member $member, Discord $discord): void resolve(ResolveUserContext::class)->handle($userDto); } catch (Throwable $throwable) { Log::channel('bot-discord')->error('WelcomeMember: failed to resolve user', [ - 'tenant_id' => $tenantProvider?->tenant_id, - 'provider' => $tenantProvider?->provider, + 'provider' => IdentityProvider::Discord->value, 'external_account_id' => $member->user->id ?? null, 'exception' => $throwable, ]); @@ -77,5 +91,103 @@ public function handle(Member $member, Discord $discord): void )) ->color('#5865F2') ->send($channelId); + + $this->sendWelcomeDm($member); + } + + /** + * Send the branded welcome DM to the new member, falling back to a public + * message in the `geral` channel when the user's DMs are closed. + */ + private function sendWelcomeDm(Member $member): void + { + $userId = (string) $member->user->id; + $guildId = (string) $member->guild_id; + $username = (string) $member->user->username; + $serverIconUrl = $this->resolveServerIconUrl($member); + + $dmDescription = sprintf( + <<<'MD' + Que bom ter você por aqui, **%s**! 💜 + + A He4rt é uma das maiores comunidades de desenvolvedores do Brasil — um lugar pra aprender junto, trocar ideia, participar de eventos e evoluir com gente que curte código. 💻 + MD, + $username, + ); + + $fallbackDescription = <<<'MD' + Tentei te dar as boas-vindas na sua DM, mas parece que ela está fechada 👀 + + Sem problema — dá pra começar por aqui mesmo! + MD; + + $this + ->buildWelcomeMessage($dmDescription, $guildId, $serverIconUrl) + ->sendTo($member->user) + ?->catch(function (Throwable $throwable) use ($userId, $guildId, $serverIconUrl, $fallbackDescription): void { + Log::channel('bot-discord')->warning('WelcomeMember: failed to deliver welcome DM', [ + 'external_account_id' => $userId, + 'exception' => $throwable, + ]); + + $geralChannelId = config('bot-discord.channels.geral'); + + if (blank($geralChannelId)) { + return; + } + + $this + ->buildWelcomeMessage($fallbackDescription, $guildId, $serverIconUrl) + ->body(sprintf('<@%s> 👋', $userId)) + ->send($geralChannelId); + }); + } + + /** + * Build the shared welcome embed (DM and channel fallback use the same + * layout, color, thumbnail, call-to-action field and buttons). The + * description sits inside the embed via `content()`; callers add a pinging + * mention via `body()` when the message is posted to a public channel. + */ + private function buildWelcomeMessage(string $description, string $guildId, ?string $serverIconUrl): Message + { + $presentationsChannelId = config()->string('bot-discord.channels.general'); + + $presentationDeepLink = sprintf( + 'https://discord.com/channels/%s/%s', + $guildId, + $presentationsChannelId + ); + + $callToAction = <<<'MD' + Toca em **Me apresentar** aqui embaixo (te levo direto pro canal certo) e manda `/apresentar`. Leva menos de um minuto — nome, nickname e um pouco sobre você. É assim que a comunidade te conhece e você desbloqueia o resto do servidor. 🚀 + MD; + + return $this + ->message() + ->title('Bem-vindo(a) à He4rt! 💜') + ->content($description) + ->thumbnailUrl($serverIconUrl) + ->field('🙋 Comece se apresentando', $callToAction, inline: false) + ->button('Me apresentar', $presentationDeepLink, '✍️') + ->button('Portal', 'https://heartdevs.com', '🌐') + ->button('Nossas redes', 'https://heartdevs.com/redes', '🔗') + ->footerText(now()->format('Y').' © He4rt Developers') + ->timestamp(now()) + ->color('#782bf1'); + } + + /** + * Resolve the guild icon as a Discord CDN URL, or null when the guild has + * no icon (the embed simply renders without a thumbnail). + */ + private function resolveServerIconUrl(Member $member): ?string + { + $iconHash = $member->guild?->icon; + + return self::guildIconUrl( + (string) $member->guild_id, + is_string($iconHash) ? $iconHash : null, + ); } } diff --git a/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php b/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php index 15f649f65..462d5eecf 100644 --- a/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php +++ b/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php @@ -42,7 +42,6 @@ public function handle(CaseReadyForEnforcement $event): void 'duration' => $this->resolveDuration($case->suggested_action), 'reason' => 'Auto-moderation triggered by rule-based classification.', 'automated' => true, - 'tenant_id' => $case->tenant_id, ]); // Async: enforcement runs in the queue (API calls, DM, delete — may fail/retry). diff --git a/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php b/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php index 7c070aded..ed1694d17 100644 --- a/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php +++ b/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php @@ -60,7 +60,6 @@ public function ingest(array $rawPayload): ModerationContentDTO 'author_external_id' => $rawPayload['author_id'], 'text' => $rawPayload['content'], 'media_urls' => $rawPayload['attachments'] ?? [], - 'tenant_id' => $rawPayload['tenant_id'] ?? null, 'metadata' => [ 'channel_id' => $rawPayload['channel_id'], 'guild_id' => $rawPayload['guild_id'], diff --git a/app-modules/bot-discord/src/SlashCommands/AbstractSlashCommand.php b/app-modules/bot-discord/src/SlashCommands/AbstractSlashCommand.php index 944d941ae..b75ea26c8 100644 --- a/app-modules/bot-discord/src/SlashCommands/AbstractSlashCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/AbstractSlashCommand.php @@ -5,7 +5,6 @@ namespace He4rt\BotDiscord\SlashCommands; use Discord\Parts\Interactions\Interaction; -use He4rt\BotDiscord\Actions\ResolveDiscordTenant; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\User\Models\User; @@ -22,8 +21,6 @@ abstract class AbstractSlashCommand extends SlashCommand { protected ?ExternalIdentity $memberProvider = null; - protected ?ExternalIdentity $tenantProvider = null; - public function maybeHandle(Interaction $interaction): void { if ($interaction->guild_id === null) { @@ -60,19 +57,13 @@ protected function processMiddleware(Interaction $interaction): mixed protected function getMemberProviderQuery(): Builder { return ExternalIdentity::query() - ->where('tenant_id', $this->tenantProvider->tenant_id) ->where('model_type', (new User)->getMorphClass()) ->where('provider', IdentityProvider::Discord); } private function beforePipeline(Interaction $interaction): void { - $this->tenantProvider = resolve(ResolveDiscordTenant::class)->handle((string) $interaction->guild_id); - - $this->memberProvider = ExternalIdentity::query() - ->where('tenant_id', $this->tenantProvider->tenant_id) - ->where('model_type', (new User)->getMorphClass()) - ->where('provider', IdentityProvider::Discord) + $this->memberProvider = $this->getMemberProviderQuery() ->where('external_account_id', $interaction->user->id) ->first(); } diff --git a/app-modules/bot-discord/src/SlashCommands/DontAskCommand.php b/app-modules/bot-discord/src/SlashCommands/DontAskCommand.php index dc4b81805..bc8ee1b15 100644 --- a/app-modules/bot-discord/src/SlashCommands/DontAskCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/DontAskCommand.php @@ -67,7 +67,7 @@ public function handle(Interaction $interaction): void ->footerIcon($interaction->guild->icon) ->thumbnailUrl($targetUser->avatar) ->footerText(Date::now()->format('Y').' © He4rt Developers') - ->imageUrl('https://media.discordapp.net/attachments/546151895010508827/1046092564513701909/Frame_1282_1.png') + ->imageUrl(asset('images/assets/linus-dont-ask-to-ask.webp')) ->timestamp(now()) ->content("Ei <@{$this->value('user')}> Explique a ideia diff --git a/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php b/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php index 314b42c97..20d977916 100644 --- a/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php @@ -65,7 +65,6 @@ public function handle(Interaction $interaction): void { $profile = Profile::query() ->where('user_id', $this->memberProvider?->user?->id) - ->where('tenant_id', $this->memberProvider?->tenant_id) ->first(); if (!$profile) { @@ -135,7 +134,6 @@ private function persistData( $profile = Profile::query() ->where('user_id', $this->memberProvider->user->id) - ->where('tenant_id', $this->memberProvider->tenant_id) ->firstOrFail(); $dto = UpsertProfileDTO::fromArray([ @@ -165,7 +163,6 @@ private function persistData( 'discord_user_id' => $interaction->user->id, 'guild_id' => $interaction->guild_id, 'user_id' => $this->memberProvider?->user?->id, - 'tenant_id' => $this->memberProvider?->tenant_id, 'fields' => ['name' => $name, 'nickname' => $nickname, 'about' => $about], 'exception' => $throwable, ]); diff --git a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php index edc00c1a0..4f829231b 100644 --- a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php @@ -8,8 +8,8 @@ use Discord\Helpers\Collection; use Discord\Parts\Guild\Role; use Discord\Parts\Interactions\Interaction; -use He4rt\BotDiscord\Actions\ResolveDiscordTenant; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\User\Actions\ResolveUserContext; use He4rt\Identity\User\Models\User; use He4rt\Profile\Actions\UpsertProfile; @@ -119,11 +119,8 @@ public function handle(Interaction $interaction): void */ private function persistData(Interaction $interaction, Collection $components): void { - $tenantProvider = resolve(ResolveDiscordTenant::class)->handle((string) $interaction->guild_id); - $userDto = ResolveUserProviderDTO::make([ - 'tenant_id' => $tenantProvider->tenant_id, - 'provider' => $tenantProvider->provider, + 'provider' => IdentityProvider::Discord, 'external_account_id' => $interaction->user->id, 'model_type' => (new User)->getMorphClass(), 'username' => $interaction->user->username, @@ -145,7 +142,7 @@ private function persistData(Interaction $interaction, Collection $components): $userContext->user->update(['name' => $name]); - $profile = Profile::ensureExists($userContext->user->id, $tenantProvider->tenant_id); + $profile = Profile::ensureExists($userContext->user->id); $dto = UpsertProfileDTO::fromArray([ 'nickname' => $nickname, diff --git a/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php b/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php index 38353067d..3ad47bd69 100644 --- a/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php @@ -90,7 +90,6 @@ public function handle(Interaction $interaction): void $profile = Profile::query() ->where('user_id', $this->memberProvider->user->id) - ->where('tenant_id', $this->memberProvider->tenant_id) ->first(); if (!$profile) { diff --git a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php new file mode 100644 index 000000000..3f8e8c06d --- /dev/null +++ b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php @@ -0,0 +1,140 @@ +member->id; + $channelId = cache()->tags(['voice_tracking'])->get('user_last_channel_'.$userId); + + /** @var list $activeChannels */ + $activeChannels = cache()->tags(['voice_channels'])->get('active_voice_channels_keys', []); + + $decision = resolve(ConfigureEmpresarialRoomAction::class)->execute( + companySlug: (string) $this->value('empresa'), + callerRoleIds: $this->callerRoleIds($interaction), + currentChannelId: $channelId ? (string) $channelId : null, + activeChannels: $activeChannels, + ); + + if (!$decision->isApproved()) { + $this->message() + ->content($decision->rejection->message()) + ->reply($interaction, ephemeral: true); + + return; + } + + $this->applyOverwrites($interaction, (string) $channelId, $decision->plan); + } + + /** + * @return array + */ + public function options(): array + { + return [ + [ + 'name' => 'empresa', + 'description' => 'Empresa parceira para a qual a sala ficará privada.', + 'type' => Option::STRING, + 'required' => true, + 'choices' => $this->companyChoices(), + ], + ]; + } + + /** + * @return list + */ + private function callerRoleIds(Interaction $interaction): array + { + $roleIds = []; + + foreach ($interaction->member->roles as $role) { + /** @var Role $role */ + $roleIds[] = $role->id; + } + + return $roleIds; + } + + private function applyOverwrites(Interaction $interaction, string $channelId, EmpresarialOverwritePlan $plan): void + { + /** @var Channel|null $voiceChannel */ + $voiceChannel = $interaction->guild->channels->get('id', $channelId); + + /** @var Role|null $partnerRole */ + $partnerRole = $interaction->guild->roles->get('id', $plan->partnerRoleId); + + // The `@everyone` role shares the guild id in Discord. + /** @var Role|null $everyoneRole */ + $everyoneRole = $interaction->guild->roles->get('id', $interaction->guild->id); + + if (!$voiceChannel || !$partnerRole || !$everyoneRole) { + $this->message() + ->content('❌ Não foi possível configurar a sala. Tente novamente.') + ->reply($interaction, ephemeral: true); + + return; + } + + try { + // Allow the partner role first: if the @everyone deny below fails, the room + // stays public rather than fully locked (deny-first would strand everyone out). + /** @var PromiseInterface $allowPartner */ + $allowPartner = $voiceChannel->setPermissions($partnerRole, $plan->allowPartnerRole, []); + await($allowPartner); + + /** @var PromiseInterface $denyEveryone */ + $denyEveryone = $voiceChannel->setPermissions($everyoneRole, [], $plan->denyEveryone); + await($denyEveryone); + + $this->message() + ->content(sprintf('✅ Sala configurada como privada para a empresa **%s**!', mb_strtoupper($plan->companySlug))) + ->reply($interaction, ephemeral: true); + } catch (Exception) { + $this->message() + ->content('❌ Erro ao configurar a sala. Tente novamente.') + ->reply($interaction, ephemeral: true); + } + } + + /** + * @return list + */ + private function companyChoices(): array + { + /** @var array $partners */ + $partners = config('bot-discord.roles.partners', []); + + return array_map( + fn (string $slug): array => ['name' => mb_strtoupper($slug), 'value' => $slug], + array_keys($partners), + ); + } +} diff --git a/app-modules/bot-discord/src/Tasks/VoiceExperienceTask.php b/app-modules/bot-discord/src/Tasks/VoiceExperienceTask.php index 50fbfcc63..9746c78b6 100644 --- a/app-modules/bot-discord/src/Tasks/VoiceExperienceTask.php +++ b/app-modules/bot-discord/src/Tasks/VoiceExperienceTask.php @@ -7,7 +7,6 @@ use Discord\Parts\WebSockets\VoiceStateUpdate; use He4rt\Activity\Voice\Actions\NewVoiceMessage; use He4rt\Activity\Voice\DTOs\NewVoiceMessageDTO; -use He4rt\BotDiscord\Actions\ResolveDiscordTenant; use He4rt\Gamification\Character\Enums\VoiceStatesEnum; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Laracord\Tasks\Task; @@ -43,14 +42,12 @@ public function handle(): void return; } - $tenantId = resolve(ResolveDiscordTenant::class)->handle($guildId)->tenant_id; - $afkChannelId = $guild->afk_channel_id; foreach ($guild->voice_states as $voiceState) { /** @var VoiceStateUpdate $voiceState */ try { - $this->processVoiceState($voiceState, $tenantId, $afkChannelId); + $this->processVoiceState($voiceState, $afkChannelId); } catch (Throwable $e) { $this->logger()->error(sprintf( 'VoiceExperienceTask failed for user %s: %s | File: %s | Line: %s', @@ -65,7 +62,6 @@ public function handle(): void private function processVoiceState( VoiceStateUpdate $voiceState, - string $tenantId, ?string $afkChannelId, ): void { if ($voiceState->channel_id === null) { @@ -85,7 +81,6 @@ private function processVoiceState( $channelId = (string) $voiceState->channel_id; $dto = new NewVoiceMessageDTO( - tenantId: $tenantId, provider: IdentityProvider::Discord, externalAccountId: (string) $voiceState->user_id, voiceState: $state, diff --git a/app-modules/bot-discord/tests/Feature/Actions/VoiceChannel/HandleStateChannelActionTest.php b/app-modules/bot-discord/tests/Feature/Actions/VoiceChannel/HandleStateChannelActionTest.php index f5e0f37bc..fd79fab2d 100644 --- a/app-modules/bot-discord/tests/Feature/Actions/VoiceChannel/HandleStateChannelActionTest.php +++ b/app-modules/bot-discord/tests/Feature/Actions/VoiceChannel/HandleStateChannelActionTest.php @@ -83,6 +83,12 @@ $action->execute($this->userId, $this->secondChannelId); + // re-read the cache: moving persists new DTO instances, so the pre-move + // snapshots above no longer reflect the current state. + $cachedChannels = cache()->tags(['voice_channels'])->get('active_voice_channels_keys'); + $firstChannelFromCache = $cachedChannels[0]; + $secondChannelFromCache = $cachedChannels[1]; + expect($cachedChannels)->not->toBeEmpty() ->and($firstChannelFromCache)->toBeInstanceOf(VoiceChannelDTO::class) ->and($firstChannelFromCache->guildId)->toBe($this->firstChannel->guildId) diff --git a/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php b/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php index fe3126693..03ef19437 100644 --- a/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php +++ b/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php @@ -5,7 +5,6 @@ use He4rt\BotDiscord\Moderation\DiscordModerationAdapter; use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\IntegrationDiscord\Transport\DiscordConnector; use He4rt\IntegrationDiscord\Transport\Requests\Bans\CreateBan; @@ -26,12 +25,10 @@ function makeUserWithDiscord(string $discordId = '999'): User { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $user->providers()->create([ 'provider' => IdentityProvider::Discord->value, 'external_account_id' => $discordId, - 'tenant_id' => $tenant->id, 'type' => 'external', 'credentials_type' => 'oauth2', 'credentials' => ClientAccessManager::make(), @@ -295,7 +292,6 @@ function mockConnector(array $responses = []): MockClient 'channel_id' => 'chan-789', 'guild_id' => 'guild-111', 'username' => 'badguy', - 'tenant_id' => null, ]; $dto = DiscordModerationAdapter::make()->ingest($payload); diff --git a/app-modules/bot-discord/tests/Feature/SlashCommands/EditProfileCommandTest.php b/app-modules/bot-discord/tests/Feature/SlashCommands/EditProfileCommandTest.php index 8014cdbb4..9fb0811f4 100644 --- a/app-modules/bot-discord/tests/Feature/SlashCommands/EditProfileCommandTest.php +++ b/app-modules/bot-discord/tests/Feature/SlashCommands/EditProfileCommandTest.php @@ -4,37 +4,38 @@ use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Profile\Actions\UpsertProfile; use He4rt\Profile\DTOs\UpsertProfileDTO; use He4rt\Profile\Models\Profile; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\ModelNotFoundException; /** - * @return array|Collection|Tenant|Collection|Profile> + * @return array|Collection|Profile> */ function createEditProfileScenario(array $profileOverrides = []): array { $user = User::factory()->create(['name' => 'OldName']); - $tenant = Tenant::factory()->create(); $user->providers()->create([ 'provider' => IdentityProvider::Discord->value, 'external_account_id' => '286313989237899276', - 'tenant_id' => $tenant->id, 'type' => 'external', 'credentials_type' => 'oauth2', 'credentials' => ClientAccessManager::make(), ]); - $profile = Profile::factory()->create(array_merge([ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - ], $profileOverrides)); + // Creating a user now auto-provisions a single profile (UserObserver), + // so update that one instead of inserting a duplicate for the same user_id. + $profile = Profile::query() + ->where('user_id', $user->id) + ->firstOrFail(); - return ['user' => $user, 'tenant' => $tenant, 'profile' => $profile]; + if ($profileOverrides !== []) { + $profile->update($profileOverrides); + } + + return ['user' => $user, 'profile' => $profile]; } describe('persistence flow', static function (): void { @@ -48,7 +49,6 @@ function createEditProfileScenario(array $profileOverrides = []): array $profile = Profile::query() ->where('user_id', $data['user']->id) - ->where('tenant_id', $data['tenant']->id) ->firstOrFail(); $dto = UpsertProfileDTO::fromArray([ @@ -66,16 +66,6 @@ function createEditProfileScenario(array $profileOverrides = []): array ->and($profile->about)->toBe('New about text is here'); }); - test('throws when profile does not exist for user', function (): void { - $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - - Profile::query() - ->where('user_id', $user->id) - ->where('tenant_id', $tenant->id) - ->firstOrFail(); - })->throws(ModelNotFoundException::class); - test('handles partial update without overwriting existing fields', function (): void { $data = createEditProfileScenario([ 'nickname' => 'ExistingNick', diff --git a/app-modules/bot-discord/tests/Feature/SlashCommands/IntroductionCommandTest.php b/app-modules/bot-discord/tests/Feature/SlashCommands/IntroductionCommandTest.php index ade586960..0170150b4 100644 --- a/app-modules/bot-discord/tests/Feature/SlashCommands/IntroductionCommandTest.php +++ b/app-modules/bot-discord/tests/Feature/SlashCommands/IntroductionCommandTest.php @@ -2,50 +2,18 @@ declare(strict_types=1); -use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Actions\ResolveUserContext; use He4rt\Identity\User\Models\User; use He4rt\Profile\Actions\UpsertProfile; use He4rt\Profile\DTOs\UpsertProfileDTO; use He4rt\Profile\Models\Profile; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\ModelNotFoundException; - -/** - * @return array|Tenant> - */ -function createIntroductionScenario(): array -{ - $tenant = Tenant::factory()->create(); - - $tenant->providers()->create([ - 'provider' => IdentityProvider::Discord->value, - 'external_account_id' => '540204204242206721', - 'tenant_id' => $tenant->id, - 'type' => 'external', - 'credentials_type' => 'oauth2', - 'credentials' => ClientAccessManager::make(), - ]); - - return ['tenant' => $tenant]; -} describe('persistence flow', static function (): void { test('resolves user context and upserts profile on introduction', function (): void { - $data = createIntroductionScenario(); - - $tenantProvider = ExternalIdentity::query() - ->where('model_type', (new Tenant)->getMorphClass()) - ->where('external_account_id', '540204204242206721') - ->firstOrFail(); - $userDto = ResolveUserProviderDTO::make([ - 'tenant_id' => $tenantProvider->tenant_id, - 'provider' => $tenantProvider->provider, + 'provider' => IdentityProvider::Discord, 'external_account_id' => '286313989237899276', 'model_type' => (new User)->getMorphClass(), 'username' => 'testuser', @@ -56,10 +24,7 @@ function createIntroductionScenario(): array $userContext->user->update(['name' => 'TestName']); - $profile = Profile::query() - ->where('user_id', $userContext->user->id) - ->where('tenant_id', $tenantProvider->tenant_id) - ->firstOrFail(); + $profile = Profile::ensureExists($userContext->user->id); $dto = UpsertProfileDTO::fromArray([ 'nickname' => 'testnick', @@ -76,24 +41,9 @@ function createIntroductionScenario(): array ->and($profile->about)->toBe('I joined He4rt because I love coding!'); }); - test('tenant provider lookup fails when guild is not registered', function (): void { - ExternalIdentity::query() - ->where('model_type', (new Tenant)->getMorphClass()) - ->where('external_account_id', '999999999999999999') - ->firstOrFail(); - })->throws(ModelNotFoundException::class); - - test('new user gets tenant pivot and profile via ResolveUserContext', function (): void { - $data = createIntroductionScenario(); - - $tenantProvider = ExternalIdentity::query() - ->where('model_type', (new Tenant)->getMorphClass()) - ->where('external_account_id', '540204204242206721') - ->firstOrFail(); - + test('new user gets a profile automatically via ResolveUserContext', function (): void { $userDto = ResolveUserProviderDTO::make([ - 'tenant_id' => $tenantProvider->tenant_id, - 'provider' => $tenantProvider->provider, + 'provider' => IdentityProvider::Discord, 'external_account_id' => '999999999999999999', 'model_type' => (new User)->getMorphClass(), 'username' => 'newuser', @@ -102,11 +52,8 @@ function createIntroductionScenario(): array $userContext = resolve(ResolveUserContext::class)->handle($userDto); - expect($userContext->user->tenants()->where('tenants.id', $data['tenant']->id)->exists())->toBeTrue(); - $profile = Profile::query() ->where('user_id', $userContext->user->id) - ->where('tenant_id', $tenantProvider->tenant_id) ->first(); expect($profile)->not->toBeNull(); diff --git a/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php new file mode 100644 index 000000000..fd48fa4ed --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php @@ -0,0 +1,134 @@ +set('bot-discord.roles.partners', [ + 'brd' => 'role-brd', + 'acme' => 'role-acme', + ]); +}); + +/** + * @param list $users + */ +function trackedRoom(string $channelId, string $ownerId = 'owner-1', array $users = []): VoiceChannelDTO +{ + return VoiceChannelDTO::make([ + 'guildId' => 'guild-1', + 'channelId' => $channelId, + 'ownerId' => $ownerId, + 'usersCount' => count($users), + 'users' => $users, + ]); +} + +it('approves a member privatizing their own /sala room', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->rejection)->toBeNull() + ->and($decision->plan)->toBeInstanceOf(EmpresarialOverwritePlan::class) + ->and($decision->plan->companySlug)->toBe('brd') + ->and($decision->plan->partnerRoleId)->toBe('role-brd') + ->and($decision->plan->denyEveryone)->toBe(['view_channel', 'connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']) + ->and($decision->plan->allowPartnerRole)->toBe(['view_channel', 'connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']); +}); + +it('approves privatizing a room a teammate created (gate is role, not ownership)', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1', ownerId: 'someone-else')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->plan->partnerRoleId)->toBe('role-brd'); +}); + +it('rejects when the caller does not hold the selected company role', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'acme', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeFalse() + ->and($decision->plan)->toBeNull() + ->and($decision->rejection)->toBe(EmpresarialRejectionReason::MissingPartnerRole); +}); + +it('rejects when the caller is in a channel that is not /sala-tracked', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'permanent-channel', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::NotInTrackedRoom); +}); + +it('rejects when the caller is not connected to any voice channel', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: null, + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::NotInTrackedRoom); +}); + +it('rejects when the selected company is not registered', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'ghost-corp', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::UnknownCompany); +}); + +it('allows only the selected company role for a member of several partners', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'acme', + callerRoleIds: ['role-brd', 'role-acme'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->plan->companySlug)->toBe('acme') + ->and($decision->plan->partnerRoleId)->toBe('role-acme'); +}); + +it('is a harmless re-stamp when run again on an already-configured room', function (): void { + $action = new ConfigureEmpresarialRoomAction(); + $args = [ + 'companySlug' => 'brd', + 'callerRoleIds' => ['role-brd'], + 'currentChannelId' => 'room-1', + 'activeChannels' => [trackedRoom('room-1')], + ]; + + $first = $action->execute(...$args); + $second = $action->execute(...$args); + + expect($first->plan->partnerRoleId)->toBe($second->plan->partnerRoleId) + ->and($first->plan->denyEveryone)->toBe($second->plan->denyEveryone) + ->and($first->plan->allowPartnerRole)->toBe($second->plan->allowPartnerRole); +}); diff --git a/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php b/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php new file mode 100644 index 000000000..0daef9de9 --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php @@ -0,0 +1,22 @@ + $reason->message(), + EmpresarialRejectionReason::cases(), + ); + + expect($messages) + ->each->toStartWith('❌') + ->and(array_unique($messages))->toHaveSameSize(EmpresarialRejectionReason::cases()); +}); + +it('maps each case to its backed slug', function (): void { + expect(EmpresarialRejectionReason::NotInTrackedRoom->value)->toBe('not-in-tracked-room') + ->and(EmpresarialRejectionReason::MissingPartnerRole->value)->toBe('missing-partner-role') + ->and(EmpresarialRejectionReason::UnknownCompany->value)->toBe('unknown-company'); +}); diff --git a/app-modules/bot-discord/tests/Unit/Events/WelcomeMemberGuildIconTest.php b/app-modules/bot-discord/tests/Unit/Events/WelcomeMemberGuildIconTest.php new file mode 100644 index 000000000..07d79ffe0 --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Events/WelcomeMemberGuildIconTest.php @@ -0,0 +1,23 @@ +toBe('https://cdn.discordapp.com/icons/540000000000000000/abc123.png'); +}); + +test('builds a gif cdn url for an animated guild icon', function (): void { + expect(WelcomeMember::guildIconUrl('540000000000000000', 'a_abc123')) + ->toBe('https://cdn.discordapp.com/icons/540000000000000000/a_abc123.gif'); +}); + +test('returns null when the guild has no icon', function (): void { + expect(WelcomeMember::guildIconUrl('540000000000000000', iconHash: null))->toBeNull(); +}); + +test('returns null when the guild id is missing', function (): void { + expect(WelcomeMember::guildIconUrl('', 'abc123'))->toBeNull(); +}); diff --git a/app-modules/community/database/factories/FeedbackFactory.php b/app-modules/community/database/factories/FeedbackFactory.php index fddec57ec..dddf5e018 100644 --- a/app-modules/community/database/factories/FeedbackFactory.php +++ b/app-modules/community/database/factories/FeedbackFactory.php @@ -5,7 +5,6 @@ namespace He4rt\Community\Database\Factories; use He4rt\Community\Feedback\Models\Feedback; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -20,7 +19,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'sender_id' => User::factory(), 'target_id' => User::factory(), 'type' => fake()->randomElement(['compliment', 'improvement']), diff --git a/app-modules/community/database/factories/MeetingFactory.php b/app-modules/community/database/factories/MeetingFactory.php index 950a73ecd..d31bbe78a 100644 --- a/app-modules/community/database/factories/MeetingFactory.php +++ b/app-modules/community/database/factories/MeetingFactory.php @@ -6,7 +6,6 @@ use He4rt\Community\Meeting\Models\Meeting; use He4rt\Community\Meeting\Models\MeetingType; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -21,7 +20,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'meeting_type_id' => MeetingType::factory(), 'admin_id' => User::factory(), 'content' => 'Fake content', diff --git a/app-modules/community/database/factories/ReviewFactory.php b/app-modules/community/database/factories/ReviewFactory.php index f7c9611e6..8dd0c2546 100644 --- a/app-modules/community/database/factories/ReviewFactory.php +++ b/app-modules/community/database/factories/ReviewFactory.php @@ -7,7 +7,6 @@ use He4rt\Community\Feedback\Enums\ReviewTypeEnum; use He4rt\Community\Feedback\Models\Feedback; use He4rt\Community\Feedback\Models\Review; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -22,7 +21,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'feedback_id' => Feedback::factory(), 'staff_id' => User::factory(), 'status' => fake()->randomElement(ReviewTypeEnum::cases()), diff --git a/app-modules/community/src/Feedback/Actions/CreateFeedback.php b/app-modules/community/src/Feedback/Actions/CreateFeedback.php index acbc57b0d..2232f02e4 100644 --- a/app-modules/community/src/Feedback/Actions/CreateFeedback.php +++ b/app-modules/community/src/Feedback/Actions/CreateFeedback.php @@ -25,7 +25,6 @@ public function handle(array $payload): Feedback return Feedback::query()->create([ ...$newFeedbackDTO->jsonSerialize(), - 'tenant_id' => request()->input('tenant_id'), ]); } diff --git a/app-modules/community/src/Feedback/Actions/ReviewFeedback.php b/app-modules/community/src/Feedback/Actions/ReviewFeedback.php index 88e2761b7..7265f8161 100644 --- a/app-modules/community/src/Feedback/Actions/ReviewFeedback.php +++ b/app-modules/community/src/Feedback/Actions/ReviewFeedback.php @@ -26,7 +26,6 @@ public function handle( Review::query()->create([ ...$reviewDTO->jsonSerialize(), - 'tenant_id' => request()->input('tenant_id'), ]); } } diff --git a/app-modules/community/src/Feedback/Models/Feedback.php b/app-modules/community/src/Feedback/Models/Feedback.php index 843d628ab..d6b8ce6dd 100644 --- a/app-modules/community/src/Feedback/Models/Feedback.php +++ b/app-modules/community/src/Feedback/Models/Feedback.php @@ -6,7 +6,6 @@ use Carbon\CarbonInterface; use He4rt\Community\Database\Factories\FeedbackFactory; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -17,7 +16,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $sender_id * @property string $target_id * @property string $type @@ -56,14 +54,6 @@ public function target(): BelongsTo return $this->belongsTo(User::class, 'target_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id'); - } - protected static function newFactory(): FeedbackFactory { return FeedbackFactory::new(); diff --git a/app-modules/community/src/Feedback/Models/Review.php b/app-modules/community/src/Feedback/Models/Review.php index 9994c117d..af9302973 100644 --- a/app-modules/community/src/Feedback/Models/Review.php +++ b/app-modules/community/src/Feedback/Models/Review.php @@ -16,7 +16,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $feedback_id * @property string $staff_id * @property ReviewTypeEnum $status diff --git a/app-modules/community/src/Meeting/Actions/StartMeeting.php b/app-modules/community/src/Meeting/Actions/StartMeeting.php index 8664f7bad..1d72e9a88 100644 --- a/app-modules/community/src/Meeting/Actions/StartMeeting.php +++ b/app-modules/community/src/Meeting/Actions/StartMeeting.php @@ -24,7 +24,6 @@ public function handle(string $provider, string $providerId, int $meetingTypeId) $externalIdentity = $this->findExternalIdentity->handle($provider, $providerId); $currentMeeting = Meeting::query()->create([ - 'tenant_id' => request()->input('tenant_id'), 'meeting_type_id' => $meetingDTO->meetingTypeId, 'admin_id' => $externalIdentity->model_id, 'starts_at' => now(), diff --git a/app-modules/community/src/Meeting/Models/Meeting.php b/app-modules/community/src/Meeting/Models/Meeting.php index 657e6ac94..4e75ea8c5 100644 --- a/app-modules/community/src/Meeting/Models/Meeting.php +++ b/app-modules/community/src/Meeting/Models/Meeting.php @@ -6,7 +6,6 @@ use Carbon\CarbonInterface; use He4rt\Community\Database\Factories\MeetingFactory; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -18,7 +17,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $admin_id * @property string|null $content * @property int $meeting_type_id @@ -60,14 +58,6 @@ public function members(): BelongsToMany )->withPivot(['attend_at']); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return BelongsTo */ diff --git a/app-modules/events/database/factories/EventFactory.php b/app-modules/events/database/factories/EventFactory.php index ed8ce12ee..0435e750f 100644 --- a/app-modules/events/database/factories/EventFactory.php +++ b/app-modules/events/database/factories/EventFactory.php @@ -11,7 +11,6 @@ use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Enums\EventType; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Date; @@ -25,7 +24,6 @@ public function definition(): array $startsAt = Date::now()->addDays(fake()->numberBetween(1, 30)); return [ - 'tenant_id' => Tenant::factory(), 'slug' => fake()->unique()->slug(), 'title' => fake()->sentence(4), 'description' => fake()->optional()->text(), diff --git a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php index 215b4e446..2e1719b45 100644 --- a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php +++ b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php @@ -14,7 +14,6 @@ public function up(): void { Schema::create('events', static function (Blueprint $table): void { $table->uuid('id')->primary(); - $table->foreignUuid('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); $table->string('slug', 120); $table->string('title', 200); $table->longText('description')->nullable(); @@ -26,8 +25,8 @@ public function up(): void ->comment(EventStatus::stringifyCases()) ->default(EventStatus::Draft); $table->timestampsTz(); - $table->unique(['tenant_id', 'slug'], 'idx_events_tenant_slug'); - $table->index(['tenant_id', 'starts_at'], 'idx_events_tenant_window'); + $table->unique('slug', 'idx_events_slug'); + $table->index('starts_at', 'idx_events_window'); $table->index(['event_type', 'starts_at'], 'idx_events_type_window'); }); } diff --git a/app-modules/events/database/seeders/EventsSeeder.php b/app-modules/events/database/seeders/EventsSeeder.php index c42a0b8a3..6690bab8e 100644 --- a/app-modules/events/database/seeders/EventsSeeder.php +++ b/app-modules/events/database/seeders/EventsSeeder.php @@ -15,7 +15,6 @@ use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Enums\EventType; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Seeder; @@ -37,14 +36,12 @@ final class EventsSeeder extends Seeder public function run(): void { - $tenant = $this->resolveTenant(); - $participants = $this->resolveParticipants($tenant); + $participants = $this->resolveParticipants(); foreach ($this->matrix() as $spec) { $event = Event::query()->firstOrCreate( ['slug' => Str::slug($spec['title'])], [ - 'tenant_id' => $tenant->id, 'title' => $spec['title'], 'description' => $spec['description'], 'event_type' => $spec['type'], @@ -449,25 +446,16 @@ private function fakeApplicationData(?array $schema): ?array return $data; } - private function resolveTenant(): Tenant - { - return Tenant::query()->where('slug', 'he4rt')->first() - ?? Tenant::query()->first() - ?? Tenant::factory()->create(['name' => 'He4rt Developers', 'slug' => 'he4rt']); - } - /** * @return Collection */ - private function resolveParticipants(Tenant $tenant): Collection + private function resolveParticipants(): Collection { - $existing = $tenant->members()->limit(self::PARTICIPANT_POOL)->get(); + $existing = User::query()->limit(self::PARTICIPANT_POOL)->get(); $missing = self::PARTICIPANT_POOL - $existing->count(); if ($missing > 0) { - $created = User::factory()->count($missing)->create(); - $tenant->members()->attach($created->pluck('id')); - $existing = $existing->concat($created); + return $existing->concat(User::factory()->count($missing)->create()); } return $existing; diff --git a/app-modules/events/src/CheckIn/Events/CheckInRequested.php b/app-modules/events/src/CheckIn/Events/CheckInRequested.php index db4eb94f0..4115756df 100644 --- a/app-modules/events/src/CheckIn/Events/CheckInRequested.php +++ b/app-modules/events/src/CheckIn/Events/CheckInRequested.php @@ -18,7 +18,6 @@ public function __construct( public IdentityProvider $provider, public string $externalUserId, public string $code, - public string $tenantId, public ?string $eventId = null, public array $channelContext = [], ) {} diff --git a/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php index a3d4afbc9..108243074 100644 --- a/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php +++ b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php @@ -52,7 +52,6 @@ private function resolveUserId(CheckInRequested $event): string return $this->findExternalIdentity->handle( provider: $event->provider->value, providerId: $event->externalUserId, - tenantId: $event->tenantId, )->model_id; } @@ -63,9 +62,8 @@ private function resolveEnrollment(CheckInRequested $event, string $userId): Enr $enrollments = Enrollment::query() ->where('user_id', $userId) ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]) - ->whereHas('event', static function (Builder $query) use ($event, $today): void { - $query->where('tenant_id', $event->tenantId) - ->where('status', EventStatus::Published) + ->whereHas('event', static function (Builder $query) use ($today): void { + $query->where('status', EventStatus::Published) ->whereDate('starts_at', '<=', $today) ->whereDate('ends_at', '>=', $today); }) diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php index c586ab26e..53d94b7ab 100644 --- a/app-modules/events/src/Event/Models/Event.php +++ b/app-modules/events/src/Event/Models/Event.php @@ -11,19 +11,16 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Enums\EventType; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; /** * @property string $id - * @property string|null $tenant_id * @property string $slug * @property string $title * @property string|null $description @@ -43,7 +40,6 @@ final class Event extends Model use HasUuids; protected $fillable = [ - 'tenant_id', 'slug', 'title', 'description', @@ -58,12 +54,6 @@ final class Event extends Model 'status' => 'draft', ]; - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return HasOne */ public function enrollmentPolicy(): HasOne { diff --git a/app-modules/events/tests/Feature/CheckInActionTest.php b/app-modules/events/tests/Feature/CheckInActionTest.php index 01a5dc38b..04196c175 100644 --- a/app-modules/events/tests/Feature/CheckInActionTest.php +++ b/app-modules/events/tests/Feature/CheckInActionTest.php @@ -14,18 +14,15 @@ use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Event as EventFacade; function createConfirmedEnrollmentForCheckIn(array $eventAttributes = [], array $enrollmentAttributes = []): Enrollment { - $tenant = Tenant::factory()->create(); $participant = User::factory()->create(); $startsAt = now()->setTime(9, 0); $event = Event::factory() - ->for($tenant) ->create(array_merge([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), diff --git a/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php index 4c6f60859..9f38d449b 100644 --- a/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php +++ b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php @@ -14,18 +14,15 @@ use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Support\Facades\Event as EventFacade; function createEventForClosure(array $eventAttributes = [], array $policyAttributes = []): Event { - $tenant = Tenant::factory()->create(); $startsAt = now()->subDays(3)->setTime(9, 0); return Event::factory() - ->for($tenant) ->has(EnrollmentPolicy::factory()->state(array_merge([ 'attendance_requirement' => AttendanceRequirement::AllDays, 'minimum_days' => null, diff --git a/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php b/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php index 650c2a0f4..5ad45b27f 100644 --- a/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php +++ b/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php @@ -9,17 +9,14 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Bus; function makeEventForCommandTest(array $eventAttributes = [], array $policyAttributes = []): Event { - $tenant = Tenant::factory()->create(); $startsAt = now()->subDays(3)->setTime(9, 0); return Event::factory() - ->for($tenant) ->has(EnrollmentPolicy::factory()->state(array_merge([ 'attendance_requirement' => AttendanceRequirement::AllDays, 'minimum_days' => null, diff --git a/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php index 9f86df03d..a1d572291 100644 --- a/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php +++ b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php @@ -13,16 +13,13 @@ use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; function createPastEvent(): Event { - $tenant = Tenant::factory()->create(); $startsAt = now()->subDays(2)->setTime(9, 0); return Event::factory() - ->for($tenant) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), diff --git a/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php index dc361862e..fe89c318e 100644 --- a/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php +++ b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php @@ -13,7 +13,6 @@ use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\Tries; @@ -24,11 +23,9 @@ function makeEventForJobTest(array $eventAttributes = [], array $policyAttributes = []): Event { - $tenant = Tenant::factory()->create(); $startsAt = now()->subDays(3)->setTime(9, 0); return Event::factory() - ->for($tenant) ->has(EnrollmentPolicy::factory()->state(array_merge([ 'attendance_requirement' => AttendanceRequirement::AllDays, 'minimum_days' => null, diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php index 19ad986ff..1068a768a 100644 --- a/app-modules/events/tests/Feature/EnrollUserActionTest.php +++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php @@ -15,17 +15,15 @@ use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Event as EventFacade; use Symfony\Component\HttpFoundation\Response; -function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $policyAttributes = []): Event +function createRsvpEvent(array $eventAttributes = [], array $policyAttributes = []): Event { return Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->rsvp()->state($policyAttributes), 'enrollmentPolicy') ->create($eventAttributes); } @@ -34,8 +32,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol EventFacade::fake([EnrollmentConfirmed::class]); $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], ['xp_on_confirmed' => 50]); + $event = createRsvpEvent([], ['xp_on_confirmed' => 50]); $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); @@ -61,8 +58,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when concurrent enrollment hits unique index, then already enrolled exception is thrown', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant); + $event = createRsvpEvent(); $dto = EnrollUserDTO::fromModels($event, $user); $raced = false; @@ -95,8 +91,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when a user enrolls twice in the same event, then duplicate enrollment is rejected', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant); + $event = createRsvpEvent(); resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); @@ -105,11 +100,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when a user enrolls in a past event, then enrollment is rejected', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->past() - ->for($tenant) ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') ->create(); @@ -118,10 +111,8 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when a user enrolls in a draft event, then enrollment is rejected', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') ->create(['status' => EventStatus::Draft]); @@ -130,11 +121,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when an event uses application enrollment method, then rsvp enrollment is rejected', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->state([ 'enrollment_method' => EnrollmentMethod::Application, ]), 'enrollmentPolicy') @@ -147,8 +136,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => 1, 'has_waitlist' => true, ]); @@ -178,8 +166,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when event is at capacity without waitlist, then enrollment is rejected with 422', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => 1, 'has_waitlist' => false, ]); @@ -204,8 +191,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when event has unlimited capacity, then all enrollments are confirmed', function (): void { EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => null, 'has_waitlist' => true, ]); @@ -226,8 +212,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when multiple users enroll beyond capacity with waitlist, then fifo waitlist positions are assigned', function (): void { EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => 2, 'has_waitlist' => true, ]); @@ -251,8 +236,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when enrollments are processed in rapid succession, then active count never exceeds capacity', function (): void { EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => 2, 'has_waitlist' => true, ]); @@ -273,8 +257,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol EventFacade::fake([EnrollmentConfirmed::class]); $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => 1, 'has_waitlist' => true, ]); @@ -301,8 +284,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol EventFacade::fake([EnrollmentConfirmed::class]); $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant, [], [ + $event = createRsvpEvent([], [ 'capacity' => 2, 'has_waitlist' => true, ]); @@ -326,8 +308,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when duplicate enrollment exists in database, then only one enrollment record is kept', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createRsvpEvent($tenant); + $event = createRsvpEvent(); resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); @@ -340,11 +321,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol EventFacade::fake([EnrollmentConfirmed::class]); $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application([ ['key' => 'why_join', 'type' => 'text', 'label' => 'Why do you want to join?', 'required' => true], ]), 'enrollmentPolicy') @@ -376,11 +355,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when application is submitted without applicationData, then exception is thrown', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') ->create(); @@ -389,11 +366,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when application is missing a required field, then exception is thrown', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application([ ['key' => 'why', 'type' => 'text', 'label' => 'Why?', 'required' => true], ]), 'enrollmentPolicy') @@ -410,11 +385,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when application is submitted with no schema defined, then enrollment is pending', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') ->create(); @@ -431,11 +404,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol test('when application event is past, then application enrollment is rejected', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->past() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') ->create(); diff --git a/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php index c1d0bc791..2612f10ba 100644 --- a/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php +++ b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php @@ -13,7 +13,6 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Event as EventFacade; @@ -30,12 +29,11 @@ function createPendingApplicationEnrollment(Event $event, ?User $user = null): E ]); } -function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): Event +function createApplicationEvent(array $policyAttributes = []): Event { return Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application()->state($policyAttributes), 'enrollmentPolicy') ->create(); } @@ -44,8 +42,7 @@ function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): E EventFacade::fake([EnrollmentConfirmed::class]); $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createApplicationEvent($tenant, ['xp_on_confirmed' => 100]); + $event = createApplicationEvent(['xp_on_confirmed' => 100]); $enrollment = createPendingApplicationEnrollment($event); $result = resolve(ApproveApplicationAction::class)->handle( @@ -73,8 +70,7 @@ function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): E EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createApplicationEvent($tenant, ['capacity' => 1, 'has_waitlist' => true]); + $event = createApplicationEvent(['capacity' => 1, 'has_waitlist' => true]); $existingUser = User::factory()->create(); Enrollment::factory()->create([ @@ -101,8 +97,7 @@ function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): E test('when event is at capacity without waitlist, then approval throws event full exception', function (): void { $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createApplicationEvent($tenant, ['capacity' => 1, 'has_waitlist' => false]); + $event = createApplicationEvent(['capacity' => 1, 'has_waitlist' => false]); $existingUser = User::factory()->create(); Enrollment::factory()->create([ @@ -122,8 +117,7 @@ function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): E test('when approving a non-pending enrollment, then exception is thrown', function (): void { $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createApplicationEvent($tenant); + $event = createApplicationEvent(); $enrollment = Enrollment::factory()->create([ 'event_id' => $event->id, @@ -142,8 +136,7 @@ function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): E EventFacade::fake([EnrollmentConfirmed::class]); $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); - $event = createApplicationEvent($tenant, ['capacity' => null]); + $event = createApplicationEvent(['capacity' => null]); $enrollments = collect(range(1, 3))->map( fn (): Enrollment => createPendingApplicationEnrollment($event), diff --git a/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php b/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php index a7ff4be33..0a575112e 100644 --- a/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php +++ b/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php @@ -10,16 +10,13 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; function createEventWithPolicy(array $eventAttributes = [], array $policyAttributes = []): Event { - $tenant = Tenant::factory()->create(); $startsAt = now()->setTime(9, 0); return Event::factory() - ->for($tenant) ->has(EnrollmentPolicy::factory()->state(array_merge([ 'attendance_requirement' => AttendanceRequirement::AllDays, 'minimum_days' => null, diff --git a/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php index bbf5768d8..5cbaa4f3a 100644 --- a/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php +++ b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php @@ -11,16 +11,13 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; test('when an application is rejected with a reason, then enrollment transitions to rejected', function (): void { $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') ->create(); @@ -57,11 +54,9 @@ test('when rejecting a non-pending enrollment, then exception is thrown', function (): void { $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') ->create(); @@ -84,11 +79,9 @@ test('when application is rejected, then application_data is preserved', function (): void { $organizer = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() ->published() ->upcoming() - ->for($tenant) ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') ->create(); diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php index f787d8e62..e785c8b98 100644 --- a/app-modules/events/tests/Feature/EventResourceTest.php +++ b/app-modules/events/tests/Feature/EventResourceTest.php @@ -15,7 +15,6 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Enums\EventType; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\PanelAdmin\Filament\Resources\Events\EventResource; use He4rt\PanelAdmin\Filament\Resources\Events\Pages\CreateEvent; @@ -28,16 +27,12 @@ beforeEach(function (): void { $admin = User::factory()->create(['username' => 'events-test-admin']); - $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $tenant->members()->attach($admin); config(['he4rt.admins' => 'events-test-admin']); $this->actingAs($admin); Filament::setCurrentPanel(Filament::getPanel('admin')); - Filament::setTenant($tenant); - $this->tenant = $tenant; }); test('when visiting the events list page, then it renders successfully', function (): void { @@ -46,7 +41,7 @@ }); test('when an event exists, then it appears in the events list', function (): void { - $event = Event::factory()->recycle($this->tenant)->create(['title' => 'He4rt Meetup #42']); + $event = Event::factory()->create(['title' => 'He4rt Meetup #42']); livewire(ListEvents::class) ->loadTable() @@ -129,17 +124,15 @@ ); }); -test('when submitting the create form with a duplicate slug for the same tenant, then validation fails', function (): void { - $tenant = Filament::getTenant(); +test('when submitting the create form with a duplicate slug, then validation fails', function (): void { $startsAt = now()->addDay(); - Event::factory()->for($tenant)->create(['slug' => 'duplicate-slug']); + Event::factory()->create(['slug' => 'duplicate-slug']); livewire(CreateEvent::class) ->fillForm([ 'title' => 'Another Event', 'slug' => 'duplicate-slug', - 'tenant_id' => $tenant->getKey(), 'event_type' => EventType::Meetup, 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->addHours(3), diff --git a/app-modules/events/tests/Feature/HandleBotCheckInTest.php b/app-modules/events/tests/Feature/HandleBotCheckInTest.php index 6300801d1..0b1feb8bd 100644 --- a/app-modules/events/tests/Feature/HandleBotCheckInTest.php +++ b/app-modules/events/tests/Feature/HandleBotCheckInTest.php @@ -14,7 +14,6 @@ use He4rt\Events\Event\Models\Event; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Date; @@ -29,17 +28,15 @@ }); /** - * @return array|User|Collection|Event|Collection|Enrollment|Collection|Collection|CheckInCode|string> + * @return array|Event|Collection|Enrollment|Collection|Collection|CheckInCode|string> */ function createBotCheckInScenario(array $codeOverrides = []): array { - $tenant = Tenant::factory()->create(); $participant = User::factory()->create(); $externalId = '900900900'; $startsAt = now()->setTime(9, 0); $event = Event::factory() - ->for($tenant) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -54,7 +51,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array ]); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'provider' => IdentityProvider::Discord, 'external_account_id' => $externalId, 'model_type' => (new User)->getMorphClass(), @@ -71,7 +67,7 @@ function createBotCheckInScenario(array $codeOverrides = []): array 'uses_count' => 0, ], $codeOverrides)); - return ['tenant' => $tenant, 'participant' => $participant, 'externalId' => $externalId, 'event' => $event, 'enrollment' => $enrollment, 'code' => $code]; + return ['participant' => $participant, 'externalId' => $externalId, 'event' => $event, 'enrollment' => $enrollment, 'code' => $code]; } test('dispatching CheckInRequested is handled by the registered listener', function (): void { @@ -81,7 +77,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, )); expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn); @@ -99,7 +94,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, channelContext: ['channel_id' => 'C1'], )); @@ -126,7 +120,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: 'unlinked-user', code: '123456', - tenantId: $scenario['tenant']->id, )); expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed); @@ -145,7 +138,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '000000', - tenantId: $scenario['tenant']->id, )); expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed); @@ -163,7 +155,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, )); EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error @@ -183,7 +174,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, )); EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error @@ -196,7 +186,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array $afternoonStart = now()->setTime(14, 0); $afternoonEvent = Event::factory() - ->for($scenario['tenant']) ->create([ 'starts_at' => $afternoonStart, 'ends_at' => $afternoonStart->clone()->setTime(20, 0), @@ -223,7 +212,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, )); expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) @@ -239,7 +227,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array $startsAt = now()->setTime(9, 0); $secondEvent = Event::factory() - ->for($scenario['tenant']) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -265,7 +252,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '999999', - tenantId: $scenario['tenant']->id, )); expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed); @@ -280,7 +266,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array $startsAt = now()->setTime(9, 0); $secondEvent = Event::factory() - ->for($scenario['tenant']) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -306,7 +291,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, )); EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error @@ -321,7 +305,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, ); resolve(HandleBotCheckIn::class)->handle($request); @@ -338,7 +321,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array $startsAt = now()->setTime(9, 0); $confirmedEvent = Event::factory() - ->for($scenario['tenant']) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -364,7 +346,6 @@ function createBotCheckInScenario(array $codeOverrides = []): array provider: IdentityProvider::Discord, externalUserId: $scenario['externalId'], code: '123456', - tenantId: $scenario['tenant']->id, )); expect($confirmedEnrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) diff --git a/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php index 9e66b3289..afd5c9af5 100644 --- a/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php +++ b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php @@ -13,7 +13,6 @@ use He4rt\Events\Enrollment\Models\Enrollment; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Date; @@ -32,12 +31,10 @@ */ function createScenarioWithCode(array $codeOverrides = []): array { - $tenant = Tenant::factory()->create(); $participant = User::factory()->create(); $startsAt = now()->setTime(9, 0); $event = Event::factory() - ->for($tenant) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -177,11 +174,9 @@ function createScenarioWithCode(array $codeOverrides = []): array }); test('uses_count is atomically incremented on each successful check-in', function (): void { - $tenant = Tenant::factory()->create(); $startsAt = now()->setTime(9, 0); $event = Event::factory() - ->for($tenant) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -235,12 +230,10 @@ function createScenarioWithCode(array $codeOverrides = []): array }); test('when same numeric code exists for multiple event dates, then current date code is used', function (): void { - $tenant = Tenant::factory()->create(); $participant = User::factory()->create(); $startsAt = now()->setTime(9, 0); $event = Event::factory() - ->for($tenant) ->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0), diff --git a/app-modules/events/tests/Feature/QrCheckInActionTest.php b/app-modules/events/tests/Feature/QrCheckInActionTest.php index 670c5d94b..c2776bd49 100644 --- a/app-modules/events/tests/Feature/QrCheckInActionTest.php +++ b/app-modules/events/tests/Feature/QrCheckInActionTest.php @@ -17,7 +17,6 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Event as EventFacade; @@ -28,12 +27,10 @@ */ function createEnrollmentWithQrToken(array $eventAttributes = [], array $enrollmentAttributes = []): array { - $tenant = Tenant::factory()->create(); $participant = User::factory()->create(); $startsAt = now()->setTime(9, 0); $event = Event::factory() - ->for($tenant) ->create(array_merge([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), @@ -120,9 +117,8 @@ function createEnrollmentWithQrToken(array $eventAttributes = [], array $enrollm test('when token belongs to a different event, qr check-in is rejected', function (): void { [, , $qrToken] = createEnrollmentWithQrToken(); - $otherTenant = Tenant::factory()->create(); $startsAt = now()->setTime(9, 0); - $otherEvent = Event::factory()->for($otherTenant)->create([ + $otherEvent = Event::factory()->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), 'status' => EventStatus::Published, @@ -139,10 +135,9 @@ function createEnrollmentWithQrToken(array $eventAttributes = [], array $enrollm }); test('when token is expired, qr check-in is rejected', function (): void { - $tenant = Tenant::factory()->create(); $startsAt = now()->setTime(9, 0); - $event = Event::factory()->for($tenant)->create([ + $event = Event::factory()->create([ 'starts_at' => $startsAt, 'ends_at' => $startsAt->clone()->setTime(18, 0), 'status' => EventStatus::Published, @@ -209,10 +204,8 @@ function createEnrollmentWithQrToken(array $eventAttributes = [], array $enrollm test('when enrollment is confirmed via enroll user action, qr token is generated by listener', function (): void { $user = User::factory()->create(); - $tenant = Tenant::factory()->create(); $event = Event::factory() - ->for($tenant) ->published() ->upcoming() ->has( diff --git a/app-modules/events/tests/Unit/EnrollmentScopeTest.php b/app-modules/events/tests/Unit/EnrollmentScopeTest.php index 54e8bed31..7d3332364 100644 --- a/app-modules/events/tests/Unit/EnrollmentScopeTest.php +++ b/app-modules/events/tests/Unit/EnrollmentScopeTest.php @@ -5,14 +5,12 @@ use He4rt\Events\Enrollment\Enums\EnrollmentStatus; use He4rt\Events\Enrollment\Models\Enrollment; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('when enrollments are queried with active scope, then only capacity-occupying statuses are included', function (): void { - $tenant = Tenant::factory()->create(); - $event = Event::factory()->published()->upcoming()->for($tenant)->create(); + $event = Event::factory()->published()->upcoming()->create(); $occupying = [ EnrollmentStatus::Confirmed, diff --git a/app-modules/gamification/database/factories/BadgeFactory.php b/app-modules/gamification/database/factories/BadgeFactory.php index 3002eefd5..a05b4aef9 100644 --- a/app-modules/gamification/database/factories/BadgeFactory.php +++ b/app-modules/gamification/database/factories/BadgeFactory.php @@ -6,7 +6,6 @@ use He4rt\Gamification\Badge\Models\Badge; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -20,7 +19,6 @@ public function definition(): array { return [ 'provider' => fake()->randomElement(IdentityProvider::cases()), - 'tenant_id' => Tenant::factory(), 'name' => fake()->name(), 'description' => fake()->sentence(), 'redeem_code' => fake()->slug(2), diff --git a/app-modules/gamification/database/factories/CharacterFactory.php b/app-modules/gamification/database/factories/CharacterFactory.php index 9730bf8e1..a13307c21 100644 --- a/app-modules/gamification/database/factories/CharacterFactory.php +++ b/app-modules/gamification/database/factories/CharacterFactory.php @@ -5,7 +5,6 @@ namespace He4rt\Gamification\Database\Factories; use He4rt\Gamification\Character\Models\Character; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Model; @@ -26,7 +25,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'user_id' => User::factory(), 'reputation' => fake()->numberBetween(1, 10), 'experience' => fake()->numberBetween(1, 5_000), diff --git a/app-modules/gamification/database/factories/PastSeasonFactory.php b/app-modules/gamification/database/factories/PastSeasonFactory.php index cf2f9d0ad..257ef8951 100644 --- a/app-modules/gamification/database/factories/PastSeasonFactory.php +++ b/app-modules/gamification/database/factories/PastSeasonFactory.php @@ -7,7 +7,6 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Gamification\Character\Models\PastSeason; use He4rt\Gamification\Season\Models\Season; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -21,7 +20,6 @@ public function definition(): array { return [ 'season_id' => Season::factory(), - 'tenant_id' => Tenant::factory(), 'character_id' => Character::factory(), 'ranking_position' => fake()->numberBetween(1, 1_000), 'experience' => fake()->numberBetween(1, 1_000), diff --git a/app-modules/gamification/database/factories/SeasonFactory.php b/app-modules/gamification/database/factories/SeasonFactory.php index 86d51a917..9722cdd58 100644 --- a/app-modules/gamification/database/factories/SeasonFactory.php +++ b/app-modules/gamification/database/factories/SeasonFactory.php @@ -5,7 +5,6 @@ namespace He4rt\Gamification\Database\Factories; use He4rt\Gamification\Season\Models\Season; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -19,7 +18,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'name' => fake()->name(), 'description' => fake()->text(), 'messages_count' => fake()->randomNumber(2), diff --git a/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php b/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php index e8bc8ae29..c6e69df2f 100644 --- a/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php +++ b/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php @@ -14,7 +14,6 @@ public function __construct( private string $description, private string $redeemCode, private bool $active, - private string $tenant_id ) {} /** @@ -28,7 +27,6 @@ public static function make(array $payload): self description: $payload['description'], redeemCode: $payload['redeem_code'], active: $payload['active'], - tenant_id: $payload['tenant_id'] ); } @@ -43,7 +41,6 @@ public function jsonSerialize(): array 'description' => $this->description, 'redeem_code' => $this->redeemCode, 'active' => $this->active, - 'tenant_id' => $this->tenant_id, ]; } } diff --git a/app-modules/gamification/src/Badge/Models/Badge.php b/app-modules/gamification/src/Badge/Models/Badge.php index da9efdb11..a693a8a9e 100644 --- a/app-modules/gamification/src/Badge/Models/Badge.php +++ b/app-modules/gamification/src/Badge/Models/Badge.php @@ -8,11 +8,9 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Gamification\Database\Factories\BadgeFactory; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Pivot; use Spatie\MediaLibrary\HasMedia; @@ -20,7 +18,6 @@ /** * @property int $id - * @property string $tenant_id * @property IdentityProvider $provider * @property string $name * @property string $description @@ -36,14 +33,6 @@ final class Badge extends Model implements HasMedia use HasFactory; use InteractsWithMedia; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return BelongsToMany */ diff --git a/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php b/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php index 192d04832..7a42d255d 100644 --- a/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php +++ b/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php @@ -9,12 +9,11 @@ final class CharacterInitializerAction { - public function ensure(User $user, string $tenantId): Character + public function ensure(User $user): Character { return Character::query()->firstOrCreate( [ 'user_id' => $user->id, - 'tenant_id' => $tenantId, ] ); } diff --git a/app-modules/gamification/src/Character/Actions/PersistClaimedBadge.php b/app-modules/gamification/src/Character/Actions/PersistClaimedBadge.php index 318556540..88f7bf632 100644 --- a/app-modules/gamification/src/Character/Actions/PersistClaimedBadge.php +++ b/app-modules/gamification/src/Character/Actions/PersistClaimedBadge.php @@ -14,7 +14,6 @@ public function handle(string $characterId, int|string $badgeId): void $character->badges() ->attach($badgeId, [ 'claimed_at' => now(), - 'tenant_id' => $character->tenant_id, ]); } } diff --git a/app-modules/gamification/src/Character/Models/Character.php b/app-modules/gamification/src/Character/Models/Character.php index b314c4ec1..a6b6efe00 100644 --- a/app-modules/gamification/src/Character/Models/Character.php +++ b/app-modules/gamification/src/Character/Models/Character.php @@ -9,7 +9,6 @@ use He4rt\Economy\Concerns\HasWallet; use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Database\Factories\CharacterFactory; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Table; @@ -32,7 +31,6 @@ * @property int $level * @property float $percentage_experience * @property bool $can_claim_daily_bonus - * @property string|null $tenant_id */ #[Appends([ 'ranking', @@ -75,14 +73,6 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return BelongsToMany */ diff --git a/app-modules/gamification/src/Character/Models/PastSeason.php b/app-modules/gamification/src/Character/Models/PastSeason.php index eec3d093c..7d5dab009 100644 --- a/app-modules/gamification/src/Character/Models/PastSeason.php +++ b/app-modules/gamification/src/Character/Models/PastSeason.php @@ -7,7 +7,6 @@ use Carbon\CarbonInterface; use He4rt\Gamification\Database\Factories\PastSeasonFactory; use He4rt\Gamification\Season\Models\Season; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,7 +15,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $season_id * @property string $character_id * @property int $ranking_position @@ -35,14 +33,6 @@ final class PastSeason extends Model use HasFactory; use HasUuids; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return BelongsTo */ diff --git a/app-modules/gamification/src/Season/Models/Season.php b/app-modules/gamification/src/Season/Models/Season.php index 8bd979510..2798a0658 100644 --- a/app-modules/gamification/src/Season/Models/Season.php +++ b/app-modules/gamification/src/Season/Models/Season.php @@ -8,17 +8,14 @@ use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Character\Models\PastSeason; use He4rt\Gamification\Database\Factories\SeasonFactory; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Date; /** - * @property string $tenant_id * @property string $name * @property string $description * @property int $messages_count @@ -59,14 +56,6 @@ public function rankings(): HasMany return $this->hasMany(PastSeason::class, 'season_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - protected static function newFactory(): SeasonFactory { return SeasonFactory::new(); diff --git a/app-modules/he4rt/resources/views/components/partials/oauth-connect.blade.php b/app-modules/he4rt/resources/views/components/partials/oauth-connect.blade.php index 74c66e369..9766ac239 100644 --- a/app-modules/he4rt/resources/views/components/partials/oauth-connect.blade.php +++ b/app-modules/he4rt/resources/views/components/partials/oauth-connect.blade.php @@ -5,7 +5,6 @@ @props([ 'providers', - 'tenant', ])
Sign in to your account to continue @@ -14,7 +13,7 @@ @foreach ($providers as $provider) @if ($provider->isEnabled()) diff --git a/app-modules/identity/database/factories/ExternalIdentityFactory.php b/app-modules/identity/database/factories/ExternalIdentityFactory.php index 39cf273e1..d23f0aeac 100644 --- a/app-modules/identity/database/factories/ExternalIdentityFactory.php +++ b/app-modules/identity/database/factories/ExternalIdentityFactory.php @@ -9,7 +9,6 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Enums\IdentityType; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Crypt; @@ -25,7 +24,6 @@ public function definition(): array { return [ 'id' => fake()->uuid(), - 'tenant_id' => Tenant::factory(), 'model_type' => (new User)->getMorphClass(), 'model_id' => User::factory(), 'type' => IdentityType::External, diff --git a/app-modules/identity/database/factories/TenantFactory.php b/app-modules/identity/database/factories/TenantFactory.php deleted file mode 100644 index 7ac408790..000000000 --- a/app-modules/identity/database/factories/TenantFactory.php +++ /dev/null @@ -1,60 +0,0 @@ - */ -final class TenantFactory extends Factory -{ - protected $model = Tenant::class; - - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'slug' => fake()->slug(), - 'active' => fake()->boolean(), - 'created_at' => Date::now(), - 'updated_at' => Date::now(), - - 'owner_id' => User::factory(), - ]; - } - - public function withOwnerAsMember(): self - { - return $this->afterCreating(fn (Tenant $tenant) => $tenant->members()->attach($tenant->owner_id)); - } - - public function withDiscordProvider(string $providerId = '123'): self - { - return $this->withProvider(IdentityProvider::Discord, $providerId); - } - - public function withTwitchProvider(string $providerId = '123'): self - { - return $this->withProvider(IdentityProvider::Twitch, '456'); - } - - public function withProvider(IdentityProvider $provider = IdentityProvider::Discord, string $providerId = '123'): self - { - return $this->afterCreating(static function (Tenant $tenant) use ($provider, $providerId): void { - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'model_type' => (new Tenant)->getMorphClass(), - 'model_id' => $tenant->getKey(), - 'connected_by' => $tenant->owner_id, - 'provider' => $provider, - 'external_account_id' => $providerId, - ]); - }); - } -} diff --git a/app-modules/identity/database/migrations/2025_11_02_172528_create_tenants_table.php b/app-modules/identity/database/migrations/2025_11_02_172528_create_tenants_table.php index 51e70064b..5aa931c9f 100644 --- a/app-modules/identity/database/migrations/2025_11_02_172528_create_tenants_table.php +++ b/app-modules/identity/database/migrations/2025_11_02_172528_create_tenants_table.php @@ -10,6 +10,10 @@ { public function up(): void { + if (Schema::hasTable('tenants')) { + return; + } + Schema::create('tenants', static function (Blueprint $table): void { $table->uuid('id')->primary(); $table->string('name'); diff --git a/app-modules/identity/database/migrations/2025_11_02_172618_set_up_tenant_module.php b/app-modules/identity/database/migrations/2025_11_02_172618_set_up_tenant_module.php index bac6772f3..9c3be03ba 100644 --- a/app-modules/identity/database/migrations/2025_11_02_172618_set_up_tenant_module.php +++ b/app-modules/identity/database/migrations/2025_11_02_172618_set_up_tenant_module.php @@ -24,7 +24,22 @@ public function up(): void 'seasons' => [], ]; + // Guarded so `migrate:fresh` stays green after multi-tenancy removal: + // if the tenants table is gone, or a target table is missing / already + // carries the column, there is nothing to set up. + if (!Schema::hasTable('tenants')) { + return; + } + foreach ($tables as $table => $indexableColumns) { + if (!Schema::hasTable($table)) { + continue; + } + + if (Schema::hasColumn($table, 'tenant_id')) { + continue; + } + Schema::table($table, static function (Blueprint $table) use ($indexableColumns): void { $table->foreignUuid('tenant_id') ->after('id') diff --git a/app-modules/identity/database/migrations/2025_11_08_161609_create_tenant_users_table.php b/app-modules/identity/database/migrations/2025_11_08_161609_create_tenant_users_table.php index a674cd9e1..efc1b37a9 100644 --- a/app-modules/identity/database/migrations/2025_11_08_161609_create_tenant_users_table.php +++ b/app-modules/identity/database/migrations/2025_11_08_161609_create_tenant_users_table.php @@ -13,6 +13,10 @@ */ public function up(): void { + if (Schema::hasTable('tenant_users') || !Schema::hasTable('tenants')) { + return; + } + Schema::create('tenant_users', static function (Blueprint $table): void { $table->foreignUuid('tenant_id')->constrained('tenants')->cascadeOnDelete(); $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete(); diff --git a/app-modules/identity/database/migrations/2025_11_25_184549_add_domain_to_tenants_table.php b/app-modules/identity/database/migrations/2025_11_25_184549_add_domain_to_tenants_table.php index 52d7d1543..2c97ff414 100644 --- a/app-modules/identity/database/migrations/2025_11_25_184549_add_domain_to_tenants_table.php +++ b/app-modules/identity/database/migrations/2025_11_25_184549_add_domain_to_tenants_table.php @@ -13,6 +13,10 @@ */ public function up(): void { + if (!Schema::hasTable('tenants') || Schema::hasColumn('tenants', 'domain')) { + return; + } + Schema::table('tenants', static function (Blueprint $table): void { $table->string('domain') ->after('owner_id') diff --git a/app-modules/identity/database/migrations/2026_07_07_000000_drop_multi_tenancy.php b/app-modules/identity/database/migrations/2026_07_07_000000_drop_multi_tenancy.php new file mode 100644 index 000000000..93f853b64 --- /dev/null +++ b/app-modules/identity/database/migrations/2026_07_07_000000_drop_multi_tenancy.php @@ -0,0 +1,233 @@ + + */ + private array $tenantColumnTables = [ + 'external_identities', + 'messages', + 'voice_messages', + 'characters', + 'feedbacks', + 'feedback_reviews', + 'meetings', + 'badges', + 'characters_badges', + 'seasons_rankings', + 'seasons', + 'activity_reactions', + 'message_mentions', + 'message_threads', + 'message_attachments', + 'message_embeds', + 'membership_events', + 'interactions', + 'moderation_events', + 'moderation_cases', + 'moderation_rules', + 'moderation_audit_log', + 'moderation_actions', + 'moderation_appeals', + 'github_repositories', + 'github_contributions', + 'twitch_subscriptions', + 'twitch_event_logs', + 'discord_guilds', + 'user_profiles', + 'activity_timeline', + ]; + + public function up(): void + { + $this->collapseTenantScopedIndexes(); + $this->dropTenantColumns(); + $this->dropTenantTables(); + } + + /** + * Best-effort, NOT fully reversible. + * + * The tenant relationships, the pivot rows, and the original tenant-scoped + * unique constraints cannot be reconstructed because the source data is + * gone. This only recreates a nullable `tenant_id` column on each table so a + * rollback does not hard-fail; it does not restore any foreign keys, the + * collapsed indexes, or the `tenants`/`tenant_users` tables. + */ + public function down(): void + { + foreach ($this->tenantColumnTables as $table) { + if (!Schema::hasTable($table)) { + continue; + } + + if (Schema::hasColumn($table, 'tenant_id')) { + continue; + } + + Schema::table($table, static function (Blueprint $blueprint): void { + $blueprint->uuid('tenant_id')->nullable(); + }); + } + } + + /** + * 1. Drop tenant-scoped indexes and recreate the collapsed global + * equivalents. Raw SQL with IF EXISTS / IF NOT EXISTS keeps this + * idempotent regardless of how each index was originally created. + */ + private function collapseTenantScopedIndexes(): void + { + $statements = [ + // messages: unique[tenant_id, provider_message_id] -> unique(provider_message_id) + 'ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_tenant_provider_message_id_unique', + 'DROP INDEX IF EXISTS messages_tenant_provider_message_id_unique', + 'CREATE UNIQUE INDEX IF NOT EXISTS messages_provider_message_id_unique ON messages (provider_message_id)', + 'DROP INDEX IF EXISTS messages_tenant_sent_at_idx', + 'DROP INDEX IF EXISTS messages_tenant_channel_sent_at_idx', + 'DROP INDEX IF EXISTS messages_tenant_kind_idx', + + // voice_messages: partial unique (tenant_id, provider_message_id) -> (provider_message_id) + 'ALTER TABLE voice_messages DROP CONSTRAINT IF EXISTS voice_messages_tenant_provider_message_id_unique', + 'DROP INDEX IF EXISTS voice_messages_tenant_provider_message_id_unique', + 'CREATE UNIQUE INDEX IF NOT EXISTS voice_messages_provider_message_id_unique ON voice_messages (provider_message_id) WHERE provider_message_id IS NOT NULL', + + // moderation_events: partial unique -> (provider_message_id) + drop tenant secondary + 'ALTER TABLE moderation_events DROP CONSTRAINT IF EXISTS moderation_events_tenant_provider_message_id_unique', + 'DROP INDEX IF EXISTS moderation_events_tenant_provider_message_id_unique', + 'CREATE UNIQUE INDEX IF NOT EXISTS moderation_events_provider_message_id_unique ON moderation_events (provider_message_id) WHERE provider_message_id IS NOT NULL', + 'DROP INDEX IF EXISTS moderation_events_tenant_id_type_occurred_at_index', + + // membership_events: partial unique -> (provider_message_id) + drop tenant secondary + 'ALTER TABLE membership_events DROP CONSTRAINT IF EXISTS membership_events_tenant_provider_unique', + 'DROP INDEX IF EXISTS membership_events_tenant_provider_unique', + 'CREATE UNIQUE INDEX IF NOT EXISTS membership_events_provider_message_id_unique ON membership_events (provider_message_id) WHERE provider_message_id IS NOT NULL', + 'DROP INDEX IF EXISTS membership_events_tenant_kind_time_idx', + + // message_threads: unique[tenant_id, provider_thread_id] -> unique(provider_thread_id) + 'ALTER TABLE message_threads DROP CONSTRAINT IF EXISTS message_threads_tenant_provider_unique', + 'DROP INDEX IF EXISTS message_threads_tenant_provider_unique', + 'CREATE UNIQUE INDEX IF NOT EXISTS message_threads_provider_thread_id_unique ON message_threads (provider_thread_id)', + + // interactions: unique[tenant_id, provider, external_ref] -> unique(provider, external_ref) + drop [tenant_id, occurred_at] + 'ALTER TABLE interactions DROP CONSTRAINT IF EXISTS uniq_interactions_tenant_provider_ref', + 'DROP INDEX IF EXISTS uniq_interactions_tenant_provider_ref', + 'CREATE UNIQUE INDEX IF NOT EXISTS uniq_interactions_provider_ref ON interactions (provider, external_ref)', + 'DROP INDEX IF EXISTS idx_interactions_tenant', + + // github_repositories: unique[tenant_id, full_name] -> unique(full_name) + 'ALTER TABLE github_repositories DROP CONSTRAINT IF EXISTS uniq_github_repositories_tenant_repo', + 'DROP INDEX IF EXISTS uniq_github_repositories_tenant_repo', + 'CREATE UNIQUE INDEX IF NOT EXISTS uniq_github_repositories_full_name ON github_repositories (full_name)', + + // github_contributions: unique[tenant_id, repo, type, external_ref] -> unique(repo, type, external_ref) + drop tenant time/type indexes + 'ALTER TABLE github_contributions DROP CONSTRAINT IF EXISTS uniq_github_contributions_ref', + 'DROP INDEX IF EXISTS uniq_github_contributions_ref', + 'CREATE UNIQUE INDEX IF NOT EXISTS uniq_github_contributions_repo_type_ref ON github_contributions (repo, type, external_ref)', + 'DROP INDEX IF EXISTS idx_github_contributions_tenant_time', + 'DROP INDEX IF EXISTS idx_github_contributions_type_time', + + // user_profiles: unique[user_id, tenant_id] -> unique(user_id) + partial (tenant_id, user_id) -> (user_id) + 'ALTER TABLE user_profiles DROP CONSTRAINT IF EXISTS user_profiles_user_id_tenant_id_unique', + 'DROP INDEX IF EXISTS user_profiles_user_id_tenant_id_unique', + 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_user_id_unique ON user_profiles (user_id)', + 'DROP INDEX IF EXISTS user_profiles_available_for_proposals_index', + 'CREATE INDEX IF NOT EXISTS user_profiles_available_for_proposals_index ON user_profiles (user_id) WHERE available_for_proposals = true', + + // Remaining tenant-scoped secondary indexes (no collapsed replacement needed). + 'DROP INDEX IF EXISTS providers_tenant_id_model_type_model_id_index', + 'DROP INDEX IF EXISTS activity_reactions_tenant_emoji_time_idx', + 'DROP INDEX IF EXISTS message_mentions_tenant_identity_idx', + 'DROP INDEX IF EXISTS message_mentions_tenant_provider_idx', + 'DROP INDEX IF EXISTS message_embeds_tenant_domain_idx', + 'DROP INDEX IF EXISTS message_embeds_tenant_kind_idx', + 'DROP INDEX IF EXISTS idx_cases_queue', + 'DROP INDEX IF EXISTS idx_rules_active', + 'DROP INDEX IF EXISTS idx_audit_tenant_date', + 'DROP INDEX IF EXISTS activity_timeline_tenant_feed_index', + 'DROP INDEX IF EXISTS activity_timeline_feed_composite_index', + 'DROP INDEX IF EXISTS twitch_subscriptions_tenant_id_index', + ]; + + foreach ($statements as $statement) { + DB::statement($statement); + } + } + + /** + * 2. Drop the tenant_id foreign key (if present) then the column itself. + */ + private function dropTenantColumns(): void + { + foreach ($this->tenantColumnTables as $table) { + if (!Schema::hasColumn($table, 'tenant_id')) { + continue; + } + + // Conventional FK name, plus the legacy `providers_*` name kept by + // external_identities. Both guarded so tables without an FK no-op. + DB::statement(sprintf('ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s_tenant_id_foreign', $table, $table)); + DB::statement(sprintf('ALTER TABLE %s DROP CONSTRAINT IF EXISTS providers_tenant_id_foreign', $table)); + + Schema::table($table, static function (Blueprint $blueprint): void { + $blueprint->dropColumn('tenant_id'); + }); + } + } + + /** + * 3. Drop the tenant pivot and the tenants table. Any lingering foreign key + * still referencing `tenants` (e.g. from tables without an in-repo + * migration) is dropped first so the DROP TABLE cannot fail. + */ + private function dropTenantTables(): void + { + if (Schema::hasTable('tenants')) { + $referencingConstraints = DB::select(<<<'SQL' + SELECT tc.table_name, tc.constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = 'tenants' + SQL); + + foreach ($referencingConstraints as $constraint) { + DB::statement(sprintf( + 'ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s', + $constraint->table_name, + $constraint->constraint_name, + )); + } + } + + Schema::dropIfExists('tenant_users'); + Schema::dropIfExists('tenants'); + } +}; diff --git a/app-modules/identity/routes/authentication-routes.php b/app-modules/identity/routes/authentication-routes.php index 9d3a005e4..6088a7407 100644 --- a/app-modules/identity/routes/authentication-routes.php +++ b/app-modules/identity/routes/authentication-routes.php @@ -3,19 +3,16 @@ declare(strict_types=1); use He4rt\Identity\Auth\Http\Controllers\OAuthController; -use He4rt\Identity\Auth\Http\Controllers\TenantLogoutController; use Illuminate\Support\Facades\Route; Route::prefix('auth') ->middleware('web') ->group(static function (): void { - Route::post('/{tenant}/logout', TenantLogoutController::class)->name('tenant.logout'); - Route::prefix('oauth')->group(static function (): void { Route::get('/{provider}', [OAuthController::class, 'getAuthenticate']) ->name('oauth.authenticate'); - Route::get('/{tenant}/{panel}/{provider}/redirect', [OAuthController::class, 'getRedirect']) + Route::get('/{panel}/{provider}/redirect', [OAuthController::class, 'getRedirect']) ->name('oauth.redirect'); }); }); diff --git a/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php b/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php index a7533acb9..84e17bc92 100644 --- a/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php +++ b/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php @@ -8,17 +8,15 @@ use He4rt\Identity\Auth\DTOs\OAuthUserDTO; use He4rt\Identity\ExternalIdentity\Enums\CredentialsType; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; final class AttachProviderToUser { - public function execute(User|Tenant $owner, Tenant $tenant, OAuthUserDTO $oauthUser, OAuthAccessDTO $access): ExternalIdentity + public function execute(User $owner, OAuthUserDTO $oauthUser, OAuthAccessDTO $access): ExternalIdentity { /** @var ExternalIdentity */ return $owner->providers()->updateOrCreate( [ - 'tenant_id' => $tenant->getKey(), 'provider' => $oauthUser->provider, 'external_account_id' => $oauthUser->providerId, ], diff --git a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php index d8ba14ee3..e079359d0 100644 --- a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php +++ b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php @@ -6,7 +6,6 @@ use He4rt\Identity\Auth\DTOs\OAuthUserDTO; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; @@ -17,22 +16,16 @@ public function __construct( private EnrichUserOnFirstLogin $enrichUser, ) {} - public function execute(OAuthUserDTO $oauthUser, Tenant $tenant): User + public function execute(OAuthUserDTO $oauthUser): User { $existing = $this->findExistingUser($oauthUser); - $user = match ($existing instanceof User) { + // Profile bootstrapping now happens on User creation (UserObserver), + // no longer as a side-effect of tenant membership. + return match ($existing instanceof User) { true => $this->enrichUser->execute($existing, $oauthUser), false => $this->createUser($oauthUser), }; - - $alreadyBelongsToTenant = $user->tenants()->where('tenants.id', $tenant->getKey())->exists(); - - if (!$alreadyBelongsToTenant) { - $user->tenants()->attach($tenant); - } - - return $user; } private function findExistingUser(OAuthUserDTO $oauthUser): ?User diff --git a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php index a76a19e76..a073d4994 100644 --- a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -11,7 +11,6 @@ use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\Auth\Exceptions\OAuthFlowException; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Auth; @@ -34,19 +33,14 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string $access = $client->auth($code); $oauthUser = $client->getAuthenticatedUser($access); - $tenant = Tenant::query() - ->where('domain', $state->tenant) - ->orWhere('slug', $state->tenant) - ->firstOrFail(); - $user = match ($state->intent) { - OAuthIntent::Login => $this->findOrCreateUser->execute($oauthUser, $tenant), + OAuthIntent::Login => $this->findOrCreateUser->execute($oauthUser), OAuthIntent::Link => $this->resolveAuthenticatedUser(), }; $redirectUrl = $state->returnUrl ?? filament() ->getPanel($state->panel) - ->getUrl($tenant); + ->getUrl(); if ($state->intent === OAuthIntent::Link) { $mergeConflict = $this->detectMergeConflict->execute($user, $oauthUser, $access); @@ -54,7 +48,6 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string if ($mergeConflict instanceof MergeConflictDTO) { return new OAuthResultDTO( user: $user, - tenant: $tenant, identity: null, intent: $state->intent, redirectUrl: $redirectUrl, @@ -63,12 +56,10 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string } } - $owner = $state->panel === 'admin' ? $tenant : $user; - $identity = $this->attachProvider->execute($owner, $tenant, $oauthUser, $access); + $identity = $this->attachProvider->execute($user, $oauthUser, $access); return new OAuthResultDTO( user: $user, - tenant: $tenant, identity: $identity, intent: $state->intent, redirectUrl: $redirectUrl, diff --git a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php index c23d43e74..3357ed60b 100644 --- a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php +++ b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php @@ -25,9 +25,7 @@ public function execute(User $currentUser, User $oldUser): void ->where('connected_by', $currentUser->id) ->update(['connected_by' => $oldUser->id]); - $oldUser->tenants()->syncWithoutDetaching( - $currentUser->tenants()->pluck('tenants.id') - ); + event(new AccountsMerged($oldUser->id, $currentUser->id)); event(new AccountsMerged($oldUser->id, $currentUser->id)); diff --git a/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php index e1c10d217..b6ee80107 100644 --- a/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php +++ b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php @@ -6,14 +6,12 @@ use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; final readonly class OAuthResultDTO { public function __construct( public User $user, - public Tenant $tenant, public ?ExternalIdentity $identity, public OAuthIntent $intent, public string $redirectUrl, diff --git a/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php b/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php index 2c266eda2..8c0cee571 100644 --- a/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php +++ b/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php @@ -16,7 +16,6 @@ public function __construct( public OAuthIntent $intent, public IdentityProvider $provider, public string $panel, - public string $tenant, public ?string $returnUrl = null, ) {} @@ -33,7 +32,6 @@ public static function fromEncryptedString(string $state): self intent: OAuthIntent::from($data['intent']), provider: IdentityProvider::from($data['provider']), panel: $data['panel'], - tenant: $data['tenant'], returnUrl: $data['return_url'] ?? null, ); } @@ -47,7 +45,6 @@ public function jsonSerialize(): array 'intent' => $this->intent->value, 'provider' => $this->provider->value, 'panel' => $this->panel, - 'tenant' => $this->tenant, 'return_url' => $this->returnUrl, ]; } diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index a5381a94a..a55cd9f8d 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -19,7 +19,7 @@ final class OAuthController extends Controller { - public function getRedirect(string $tenant, string $panel, string $provider): RedirectResponse + public function getRedirect(string $panel, string $provider): RedirectResponse { $identityProvider = IdentityProvider::tryFrom($provider); @@ -39,7 +39,6 @@ public function getRedirect(string $tenant, string $panel, string $provider): Re intent: Auth::check() ? OAuthIntent::Link : OAuthIntent::Login, provider: $identityProvider, panel: $panel, - tenant: $tenant, returnUrl: Auth::check() ? url()->previous() : null, ); @@ -77,12 +76,19 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act return redirect()->to($result->redirectUrl); } + $redirectUrl = $result->redirectUrl; + if ($result->intent === OAuthIntent::Login) { - Auth::login($result->user); + Auth::login($result->user, remember: true); filament()->setCurrentPanel(filament()->getPanel($state->panel)); - filament()->setTenant($result->tenant); + + if ($state->panel === 'app') { + $redirectUrl = url()->query($redirectUrl, [ + 'oauth_provider' => $identityProvider->value, + ]); + } } - return redirect()->to($result->redirectUrl); + return redirect()->to($redirectUrl); } } diff --git a/app-modules/identity/src/Auth/Http/Controllers/TenantLogoutController.php b/app-modules/identity/src/Auth/Http/Controllers/TenantLogoutController.php deleted file mode 100644 index 676a01d21..000000000 --- a/app-modules/identity/src/Auth/Http/Controllers/TenantLogoutController.php +++ /dev/null @@ -1,25 +0,0 @@ -get('panel'); - Filament::auth()->logout(); - - session()->invalidate(); - session()->regenerateToken(); - session()->put('tenant', $tenantSlug); - session()->put('panel', $panel); - - return resolve(TenantLogoutResponse::class); - } -} diff --git a/app-modules/identity/src/Auth/Http/Responses/TenantLogoutResponse.php b/app-modules/identity/src/Auth/Http/Responses/TenantLogoutResponse.php deleted file mode 100644 index 0c7dabb66..000000000 --- a/app-modules/identity/src/Auth/Http/Responses/TenantLogoutResponse.php +++ /dev/null @@ -1,26 +0,0 @@ -get('tenant'); - $panel = session()->get('panel'); - $tenant = Tenant::query()->where('slug', $tenantSlug)->first(); - - Filament::setCurrentPanel($panel); - - return redirect()->to( - Filament::hasLogin() ? Filament::getLoginUrl(['tenant' => $tenantSlug]) : Filament::getUrl($tenant), - ); - } -} diff --git a/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php index 9e9dcb784..1418af0ec 100644 --- a/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php @@ -13,7 +13,7 @@ class CreateAccountByExternalIdentity { - public function handle(string $tenantId, IdentityProvider $provider, string $providerId, string $username): ExternalIdentity + public function handle(IdentityProvider $provider, string $providerId, string $username): ExternalIdentity { $existing = ExternalIdentity::query() ->where('provider', $provider->value) @@ -31,12 +31,9 @@ public function handle(string $tenantId, IdentityProvider $provider, string $pro 'is_donator' => false, ]); - $user->character()->create([ - 'tenant_id' => $tenantId, - ]); + $user->character()->create(); return ExternalIdentity::query()->create([ - 'tenant_id' => $tenantId, 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'type' => $provider->getType(), diff --git a/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php index 834a52a29..b396cb915 100644 --- a/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php @@ -10,25 +10,20 @@ class FindExternalIdentity { - public function handle(string $provider, string $providerId, ?string $tenantId = null): ExternalIdentity + public function handle(string $provider, string $providerId): ExternalIdentity { - $cacheKey = $tenantId !== null - ? sprintf('provider-%s-%s-%s', $provider, $providerId, $tenantId) - : sprintf('provider-%s-%s', $provider, $providerId); + $cacheKey = sprintf('provider-%s-%s', $provider, $providerId); return Cache::remember( $cacheKey, 2 * 86_400, - fn () => $this->find($provider, $providerId, $tenantId) + fn () => $this->find($provider, $providerId) ); } - private function find(string $provider, string $providerId, ?string $tenantId = null): ExternalIdentity + private function find(string $provider, string $providerId): ExternalIdentity { - $resolvedTenantId = $tenantId ?? request()->input('tenant_id'); - $model = ExternalIdentity::query() - ->where('tenant_id', $resolvedTenantId) ->where('provider', $provider) ->where('external_account_id', $providerId) ->first(); diff --git a/app-modules/identity/src/ExternalIdentity/Actions/ResolveExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Actions/ResolveExternalIdentity.php index ba7ed3a5f..0809d19bc 100644 --- a/app-modules/identity/src/ExternalIdentity/Actions/ResolveExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Actions/ResolveExternalIdentity.php @@ -16,7 +16,6 @@ public function handle(ResolveUserProviderDTO $dto): ExternalIdentity return ExternalIdentity::query()->firstOrCreate( [ 'provider' => $dto->provider, - 'tenant_id' => $dto->tenantId, 'external_account_id' => $dto->externalAccountId, 'model_type' => $dto->modelType, ], diff --git a/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php b/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php index 5f6bcffe1..c7922729e 100644 --- a/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php +++ b/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php @@ -10,7 +10,6 @@ final readonly class NewProviderDTO implements JsonSerializable { public function __construct( - private string $tenantId, private IdentityProvider $provider, private string $externalAccountId ) {} @@ -21,7 +20,6 @@ public function __construct( public function jsonSerialize(): array { return [ - 'tenant_id' => $this->tenantId, 'provider' => $this->provider->value, 'external_account_id' => $this->externalAccountId, ]; diff --git a/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php b/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php index 24718d22b..c8ced22bf 100644 --- a/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php +++ b/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php @@ -9,7 +9,6 @@ class ResolveUserProviderDTO { public function __construct( - public string $tenantId, public IdentityProvider $provider, public string $externalAccountId, public string $modelType, @@ -24,7 +23,6 @@ public function __construct( public static function make(array $data): self { return new self( - tenantId: $data['tenant_id'], provider: $data['provider'], externalAccountId: $data['external_account_id'], modelType: $data['model_type'], diff --git a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php index 2a15f9867..c3c717e50 100644 --- a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php +++ b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php @@ -195,10 +195,9 @@ public function isEnabled(): bool return config(sprintf('services.%s.enabled', $this->value)); } - public function getRedirectUri(?string $tenant = null): string + public function getRedirectUri(): string { return route('oauth.redirect', [ - 'tenant' => $tenant ?? request()->getHost(), 'panel' => filament()->getCurrentPanel()->getId(), 'provider' => $this->value, ]); diff --git a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php index 666cccd82..d53ead97b 100644 --- a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php @@ -12,7 +12,6 @@ use He4rt\Identity\ExternalIdentity\Enums\CredentialsType; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Enums\IdentityType; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Table; @@ -26,7 +25,6 @@ /** * @property string $id - * @property string $tenant_id * @property string $model_type * @property string $model_id * @property IdentityType $type @@ -84,14 +82,6 @@ public function messages(): HasMany return $this->hasMany(Message::class, 'external_identity_id'); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_id', 'id'); - } - public function isConnected(): bool { return $this->connected_at !== null && $this->disconnected_at === null; diff --git a/app-modules/identity/src/IdentityServiceProvider.php b/app-modules/identity/src/IdentityServiceProvider.php index 3fe60d0be..af40eb289 100644 --- a/app-modules/identity/src/IdentityServiceProvider.php +++ b/app-modules/identity/src/IdentityServiceProvider.php @@ -4,7 +4,6 @@ namespace He4rt\Identity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; @@ -17,7 +16,6 @@ public function boot(): void Relation::morphMap([ 'user' => User::class, - 'tenant' => Tenant::class, ]); } } diff --git a/app-modules/identity/src/Tenant/Concerns/InteractsWithTenants.php b/app-modules/identity/src/Tenant/Concerns/InteractsWithTenants.php deleted file mode 100644 index 798b3f9c2..000000000 --- a/app-modules/identity/src/Tenant/Concerns/InteractsWithTenants.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ - public function tenants(): BelongsToMany - { - return $this->belongsToMany(Tenant::class, 'tenant_users')->using(TenantUser::class); - } - - public function canAccessTenant(Model $tenant): bool - { - return $this->tenants()->whereKey($tenant)->exists(); - } - - /** - * @return array|Collection - */ - public function getTenants(Panel $panel): array|Collection - { - return $this->tenants; - } - - /** - * @return HasMany - */ - public function ownedTenants(): HasMany - { - return $this->hasMany(Tenant::class, 'owner_id'); - } -} diff --git a/app-modules/identity/src/Tenant/Models/Tenant.php b/app-modules/identity/src/Tenant/Models/Tenant.php deleted file mode 100644 index d3390c47a..000000000 --- a/app-modules/identity/src/Tenant/Models/Tenant.php +++ /dev/null @@ -1,101 +0,0 @@ - */ - use HasFactory; - - use HasUuids; - use SoftDeletes; - - /** - * @return BelongsTo - */ - public function owner(): BelongsTo - { - return $this->belongsTo(User::class, 'owner_id'); - } - - /** - * @return HasMany - */ - public function messages(): HasMany - { - return $this->hasMany(Message::class); - } - - /** - * @return BelongsToMany - */ - public function members(): BelongsToMany - { - return $this->belongsToMany(User::class, 'tenant_users')->using(TenantUser::class); - } - - /** - * @return MorphMany - */ - public function providers(): MorphMany - { - return $this->morphMany(ExternalIdentity::class, 'model'); - } - - /** - * @return HasMany - */ - public function seasons(): HasMany - { - return $this->hasMany(Season::class); - } - - /** - * @return HasMany - */ - public function pastSeasons(): HasMany - { - return $this->hasMany(PastSeason::class); - } - - protected static function newFactory(): TenantFactory - { - return TenantFactory::new(); - } - - protected function casts(): array - { - return [ - 'active' => 'boolean', - ]; - } -} diff --git a/app-modules/identity/src/Tenant/Models/TenantUser.php b/app-modules/identity/src/Tenant/Models/TenantUser.php deleted file mode 100644 index 51d9e6b33..000000000 --- a/app-modules/identity/src/Tenant/Models/TenantUser.php +++ /dev/null @@ -1,18 +0,0 @@ -user_id, (string) $pivot->tenant_id); - } -} diff --git a/app-modules/identity/src/User/Actions/ResolveUserContext.php b/app-modules/identity/src/User/Actions/ResolveUserContext.php index 93b653f17..c10ca3027 100644 --- a/app-modules/identity/src/User/Actions/ResolveUserContext.php +++ b/app-modules/identity/src/User/Actions/ResolveUserContext.php @@ -22,8 +22,7 @@ public function handle(ResolveUserProviderDTO $dto): UserContext { $provider = $this->providerResolver->handle($dto); $user = $this->userResolver->handle($provider); - $user->tenants()->syncWithoutDetaching([$dto->tenantId]); - $character = $this->characterInitializer->ensure($user, $dto->tenantId); + $character = $this->characterInitializer->ensure($user); return UserContext::make(user: $user, character: $character, provider: $provider); } diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index 4bb7a97c8..e91b55c87 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -8,12 +8,10 @@ use Carbon\CarbonInterface; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; -use Filament\Models\Contracts\HasTenants; use Filament\Panel; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\Database\Factories\UserFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Concerns\InteractsWithTenants; use He4rt\Identity\User\Observers\UserObserver; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -44,14 +42,13 @@ #[ObservedBy(classes: UserObserver::class)] #[Table(name: 'users')] #[Hidden('password', 'remember_token', 'email_verified_at')] -final class User extends Authenticatable implements FilamentUser, HasMedia, HasName, HasTenants +final class User extends Authenticatable implements FilamentUser, HasMedia, HasName { use HasAddress; /** @use HasFactory */ use HasFactory; use HasUuids; use InteractsWithMedia; - use InteractsWithTenants; use Notifiable; public function isAdmin(): bool diff --git a/app-modules/identity/src/User/Observers/UserObserver.php b/app-modules/identity/src/User/Observers/UserObserver.php index 026acfc77..e6b6c2548 100644 --- a/app-modules/identity/src/User/Observers/UserObserver.php +++ b/app-modules/identity/src/User/Observers/UserObserver.php @@ -5,6 +5,7 @@ namespace He4rt\Identity\User\Observers; use He4rt\Identity\User\Models\User; +use He4rt\Profile\Models\Profile; class UserObserver { @@ -15,8 +16,23 @@ public function creating(User $user): void } } + public function created(User $user): void + { + $this->ensureProfileExists($user); + } + public function deleted(User $user): void { $user->address()->delete(); } + + /** + * Previously a profile was created as a side-effect of attaching a user to a + * tenant (TenantUserObserver). With tenancy removed, every newly created user + * gets a profile here instead. + */ + private function ensureProfileExists(User $user): void + { + Profile::ensureExists((string) $user->getKey()); + } } diff --git a/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php b/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php index 9507a972f..a50f4e9b8 100644 --- a/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php +++ b/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php @@ -7,7 +7,6 @@ use He4rt\Identity\Auth\DTOs\OAuthUserDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; function makeMergeOAuthUser( @@ -54,11 +53,9 @@ public static function make(array $payload): self }); test('returns null when identity belongs to current user', function (): void { - $tenant = Tenant::factory()->create(); $currentUser = User::factory()->create(); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $currentUser->id, 'provider' => IdentityProvider::Discord, @@ -75,12 +72,10 @@ public static function make(array $payload): self }); test('returns conflict when identity belongs to a different user', function (): void { - $tenant = Tenant::factory()->create(); $oldUser = User::factory()->create(); $currentUser = User::factory()->create(); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $oldUser->id, 'provider' => IdentityProvider::Discord, @@ -97,48 +92,3 @@ public static function make(array $payload): self ->and($result->conflictingUserId)->toBe($oldUser->id) ->and($result->provider)->toBe(IdentityProvider::Discord); }); - -test('detects conflict cross-tenant', function (): void { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $oldUser = User::factory()->create(); - $currentUser = User::factory()->create(); - - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenantA->id, - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $oldUser->id, - 'provider' => IdentityProvider::Discord, - 'external_account_id' => '204122995579551744', - ]); - - $oauthUser = makeMergeOAuthUser(providerId: '204122995579551744'); - $credentials = makeMergeCredentials(); - - $action = new DetectMergeConflict(); - $result = $action->execute($currentUser, $oauthUser, $credentials); - - expect($result)->not->toBeNull() - ->and($result->conflictingUserId)->toBe($oldUser->id); -}); - -test('ignores tenant-owned identities', function (): void { - $tenant = Tenant::factory()->create(); - $currentUser = User::factory()->create(); - - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, - 'model_type' => (new Tenant)->getMorphClass(), - 'model_id' => $tenant->id, - 'provider' => IdentityProvider::Discord, - 'external_account_id' => '204122995579551744', - ]); - - $oauthUser = makeMergeOAuthUser(providerId: '204122995579551744'); - $credentials = makeMergeCredentials(); - - $action = new DetectMergeConflict(); - $result = $action->execute($currentUser, $oauthUser, $credentials); - - expect($result)->toBeNull(); -}); diff --git a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php index 2e5e7167d..8ad6db6c6 100644 --- a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php +++ b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php @@ -7,7 +7,6 @@ use He4rt\Identity\Auth\DTOs\OAuthUserDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; function makeOAuthUser( @@ -34,13 +33,10 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self }; } -test('finds existing user by external identity cross-tenant', function (): void { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); +test('finds existing user by external identity', function (): void { $user = User::factory()->create(); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenantA->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'provider' => IdentityProvider::GitHub, @@ -50,27 +46,23 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub), - $tenantB, ); expect($result->id)->toBe($user->id); }); test('finds existing user by email', function (): void { - $tenant = Tenant::factory()->create(); $user = User::factory()->create(['email' => 'daniel@example.com']); $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', email: 'daniel@example.com'), - $tenant, ); expect($result->id)->toBe($user->id); }); test('does not search by email when email is null', function (): void { - $tenant = Tenant::factory()->create(); User::factory()->create(['email' => null, 'username' => 'someone']); $userCountBefore = User::query()->count(); @@ -78,7 +70,6 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'newuser', email: null), - $tenant, ); expect($result->username)->toBe('newuser'); @@ -86,12 +77,9 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self }); test('creates new user when no match found', function (): void { - $tenant = Tenant::factory()->create(); - $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'fresh-id', username: 'freshuser', name: 'Fresh User', email: 'fresh@example.com'), - $tenant, ); expect($result->username)->toBe('freshuser') @@ -100,13 +88,11 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self }); test('creates user with sequential suffix when username collides', function (): void { - $tenant = Tenant::factory()->create(); User::factory()->create(['username' => 'danielhe4rt']); $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'danielhe4rt', email: 'new@example.com'), - $tenant, ); expect($result->username)->toBe('danielhe4rt-2') @@ -114,7 +100,6 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self }); test('increments suffix when previous suffixed usernames exist', function (): void { - $tenant = Tenant::factory()->create(); User::factory()->create(['username' => 'danielhe4rt']); User::factory()->create(['username' => 'danielhe4rt-2']); User::factory()->create(['username' => 'danielhe4rt-3']); @@ -122,63 +107,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'danielhe4rt', email: 'new@example.com'), - $tenant, ); expect($result->username)->toBe('danielhe4rt-4'); }); - -test('attaches user to tenant when not already attached', function (): void { - $tenant = Tenant::factory()->create(); - - $action = resolve(FindOrCreateUserByProvider::class); - $result = $action->execute( - makeOAuthUser(providerId: 'new-id', username: 'newuser'), - $tenant, - ); - - expect($result->tenants()->where('tenants.id', $tenant->getKey())->exists())->toBeTrue(); -}); - -test('does not duplicate tenant attachment when already attached', function (): void { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $user->tenants()->attach($tenant); - - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $user->id, - 'provider' => IdentityProvider::GitHub, - 'external_account_id' => '12345', - ]); - - $action = resolve(FindOrCreateUserByProvider::class); - $action->execute( - makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub), - $tenant, - ); - - expect($user->tenants()->count())->toBe(1); -}); - -test('ignores tenant-owned external identities during lookup', function (): void { - $tenant = Tenant::factory()->create(); - - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, - 'model_type' => (new Tenant)->getMorphClass(), - 'model_id' => $tenant->id, - 'provider' => IdentityProvider::Discord, - 'external_account_id' => '204122995579551744', - ]); - - $action = resolve(FindOrCreateUserByProvider::class); - $result = $action->execute( - makeOAuthUser(providerId: '204122995579551744', provider: IdentityProvider::Discord, username: 'newuser'), - $tenant, - ); - - expect($result->username)->toBe('newuser'); - expect(User::query()->where('username', 'newuser')->exists())->toBeTrue(); -}); diff --git a/app-modules/identity/tests/Feature/Auth/HandleOAuthCallbackActionTest.php b/app-modules/identity/tests/Feature/Auth/HandleOAuthCallbackActionTest.php index cd8c6fd66..cd98cc5ec 100644 --- a/app-modules/identity/tests/Feature/Auth/HandleOAuthCallbackActionTest.php +++ b/app-modules/identity/tests/Feature/Auth/HandleOAuthCallbackActionTest.php @@ -10,7 +10,6 @@ use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\IntegrationGithub\OAuth\GitHubOAuthClient; @@ -67,11 +66,9 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO test('links a second provider to the logged-in user even when the email differs', function (): void { // A user who originally signed up via Discord (discord@discord.com)... - $tenant = Tenant::factory()->create(); $discordUser = User::factory()->create(['email' => 'discord@discord.com']); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $discordUser->id, 'connected_by' => $discordUser->id, @@ -88,7 +85,6 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO intent: OAuthIntent::Link, provider: IdentityProvider::GitHub, panel: 'app', - tenant: $tenant->slug, returnUrl: 'https://he4rt.test/app', ); @@ -121,11 +117,9 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO test('forks a separate account when a logged-out github login has a different email', function (): void { // Same starting point — a Discord user (discord@discord.com). - $tenant = Tenant::factory()->create(); $discordUser = User::factory()->create(['email' => 'discord@discord.com']); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $discordUser->id, 'connected_by' => $discordUser->id, @@ -143,7 +137,6 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO intent: OAuthIntent::Login, provider: IdentityProvider::GitHub, panel: 'app', - tenant: $tenant->slug, returnUrl: 'https://he4rt.test/app', ); @@ -168,5 +161,5 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO ->and($githubIdentity->model_id)->toBe($result->user->id) ->and($githubIdentity->model_id)->not->toBe($discordUser->id); - assertDatabaseCount(User::class, 3); + assertDatabaseCount(User::class, 2); }); diff --git a/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php b/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php index d6fabb133..29d138baf 100644 --- a/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php +++ b/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php @@ -7,16 +7,13 @@ use He4rt\Identity\Auth\Actions\MergeAccountsAction; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; test('moves external identities from current to old user', function (): void { - $tenant = Tenant::factory()->create(); $oldUser = User::factory()->create(['first_login_at' => now()]); $currentUser = User::factory()->create(); $identity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $currentUser->id, 'provider' => IdentityProvider::GitHub, @@ -30,29 +27,11 @@ expect($identity->model_id)->toBe($oldUser->id); }); -test('syncs tenant memberships from current to old user', function (): void { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $oldUser = User::factory()->create(['first_login_at' => now()]); - $currentUser = User::factory()->create(); - - $oldUser->tenants()->attach($tenantA); - $currentUser->tenants()->attach([$tenantA->id, $tenantB->id]); - - $action = new MergeAccountsAction(); - $action->execute($currentUser, $oldUser); - - expect($oldUser->tenants()->pluck('tenants.id')->sort()->values()->toArray()) - ->toBe(collect([$tenantA->id, $tenantB->id])->sort()->values()->all()); -}); - test('reassigns timeline posts from current to old user so they are not orphaned', function (): void { - $tenant = Tenant::factory()->create(); $oldUser = User::factory()->create(['first_login_at' => now()]); $currentUser = User::factory()->create(); $post = Timeline::factory()->for($currentUser)->create([ - 'tenant_id' => $tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => PostEntry::factory()->create()->id, ]); @@ -75,12 +54,10 @@ }); test('reassigns connected_by from current to old user', function (): void { - $tenant = Tenant::factory()->create(); $oldUser = User::factory()->create(['first_login_at' => now()]); $currentUser = User::factory()->create(); $identity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $oldUser->id, 'provider' => IdentityProvider::GitHub, @@ -96,12 +73,10 @@ }); test('reassigns connected_by on soft-deleted identities', function (): void { - $tenant = Tenant::factory()->create(); $oldUser = User::factory()->create(['first_login_at' => now()]); $currentUser = User::factory()->create(); $identity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $oldUser->id, 'provider' => IdentityProvider::GitHub, diff --git a/app-modules/identity/tests/Feature/Auth/OAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/OAuthControllerTest.php new file mode 100644 index 000000000..5ba8e1293 --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/OAuthControllerTest.php @@ -0,0 +1,121 @@ +instance(GitHubOAuthClient::class, new readonly class($access, $user) implements OAuthClientContract + { + public function __construct( + private OAuthAccessDTO $access, + private OAuthUserDTO $user, + ) {} + + public function redirectUrl(?OAuthStateDTO $state = null): string + { + return 'https://github.test/oauth'; + } + + public function auth(string $code): OAuthAccessDTO + { + return $this->access; + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO + { + return $this->user; + } + }); +} + +function callGithubCallback(OAuthStateDTO $state): string +{ + request()->merge([ + 'state' => (string) $state, + 'code' => 'auth-code', + ]); + + return resolve(OAuthController::class) + ->getAuthenticate('github', resolve(HandleOAuthCallbackAction::class)) + ->getTargetUrl(); +} + +test('successful app oauth login marks the provider in the redirect URL', function (): void { + Filament::setCurrentPanel(Filament::getPanel('app')); + bindControllerGithubClient(); + + $targetUrl = callGithubCallback(new OAuthStateDTO( + intent: OAuthIntent::Login, + provider: IdentityProvider::GitHub, + panel: 'app', + returnUrl: '/app?source=oauth', + )); + + expect($targetUrl) + ->toContain('source=oauth') + ->toContain('oauth_provider=github') + ->and(Auth::check())->toBeTrue(); +}); + +test('denied app oauth login does not mark a provider in the redirect URL', function (): void { + $state = new OAuthStateDTO( + intent: OAuthIntent::Login, + provider: IdentityProvider::GitHub, + panel: 'app', + returnUrl: '/app/login', + ); + + request()->merge([ + 'state' => (string) $state, + 'error' => 'access_denied', + ]); + + $targetUrl = resolve(OAuthController::class) + ->getAuthenticate('github', resolve(HandleOAuthCallbackAction::class)) + ->getTargetUrl(); + + expect($targetUrl) + ->toContain('/app/login') + ->not->toContain('oauth_provider='); +}); diff --git a/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php b/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php index e2fe99af2..978b0296e 100644 --- a/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php +++ b/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php @@ -6,19 +6,16 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Exceptions\ExternalIdentityException; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; uses(RefreshDatabase::class); -test('finds identity when tenantId is passed explicitly', function (): void { - $tenant = Tenant::factory()->create(); +test('finds identity by provider and providerId', function (): void { $user = User::factory()->create(); $identity = ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'provider' => IdentityProvider::Discord, @@ -29,19 +26,16 @@ $result = $action->handle( provider: IdentityProvider::Discord->value, providerId: 'discord-999', - tenantId: (string) $tenant->id, ); expect($result->id)->toBe($identity->id) ->and($result->model_id)->toBe($user->id); }); -test('cache key includes tenantId when provided', function (): void { - $tenant = Tenant::factory()->create(); +test('caches the lookup result', function (): void { $user = User::factory()->create(); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'provider' => IdentityProvider::Discord, @@ -54,70 +48,17 @@ $action->handle( provider: IdentityProvider::Discord->value, providerId: 'discord-cache-test', - tenantId: (string) $tenant->id, ); - $expectedKey = sprintf('provider-%s-%s-%s', IdentityProvider::Discord->value, 'discord-cache-test', $tenant->id); + $expectedKey = sprintf('provider-%s-%s', IdentityProvider::Discord->value, 'discord-cache-test'); expect(Cache::has($expectedKey))->toBeTrue(); }); -test('cache key excludes tenantId when null', function (): void { - Cache::flush(); - - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $user->id, - 'provider' => IdentityProvider::Discord, - 'external_account_id' => 'discord-no-tenant', - ]); - - // Simulate request with tenant_id - request()->merge(['tenant_id' => (string) $tenant->id]); - - $action = new FindExternalIdentity(); - $action->handle( - provider: IdentityProvider::Discord->value, - providerId: 'discord-no-tenant', - ); - - $expectedKey = sprintf('provider-%s-%s', IdentityProvider::Discord->value, 'discord-no-tenant'); - expect(Cache::has($expectedKey))->toBeTrue(); -}); - -test('null tenantId falls back to request input', function (): void { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - - ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $user->id, - 'provider' => IdentityProvider::Discord, - 'external_account_id' => 'discord-fallback', - ]); - - request()->merge(['tenant_id' => (string) $tenant->id]); - - $action = new FindExternalIdentity(); - $result = $action->handle( - provider: IdentityProvider::Discord->value, - providerId: 'discord-fallback', - ); - - expect($result->model_id)->toBe($user->id); -}); - test('throws exception when identity not found', function (): void { - $tenant = Tenant::factory()->create(); $action = new FindExternalIdentity(); $action->handle( provider: IdentityProvider::Discord->value, providerId: 'nonexistent-id', - tenantId: (string) $tenant->id, ); })->throws(ExternalIdentityException::class); diff --git a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php index 05a0ea022..7ab07ca6e 100644 --- a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php +++ b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php @@ -121,7 +121,6 @@ private function processArticle(array $article): string $this->trackActivity->handle(new TrackActivityDTO( characterId: (string) $character->id, - tenantId: (string) $externalIdentity->tenant_id, type: ActivityType::Article, provider: IdentityProvider::DevTo, occurredAt: new DateTimeImmutable($article['published_at'] ?? $article['created_at']), diff --git a/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php b/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php index 841770c44..78a13d4ae 100644 --- a/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php +++ b/app-modules/integration-devto/tests/Feature/SyncDevToArticlesTest.php @@ -7,7 +7,6 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Http; @@ -46,12 +45,10 @@ ]), ]); - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + $character = Character::factory()->recycle($user)->create(); ExternalIdentity::factory()->create([ - 'tenant_id' => $tenant->id, 'model_type' => User::class, 'model_id' => $user->id, 'provider' => IdentityProvider::DevTo, @@ -94,19 +91,17 @@ ]), ]); - $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $character = Character::factory()->recycle($user)->recycle($tenant)->create(); + $character = Character::factory()->recycle($user)->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([ + Interaction::factory()->recycle($character)->create([ 'external_ref' => 'devto:article:201', 'metadata' => [ 'engagement_snapshot' => ['reactions' => 20, 'comments' => 5, 'bookmarks' => 1], diff --git a/app-modules/integration-discord/database/factories/DiscordEventLogFactory.php b/app-modules/integration-discord/database/factories/DiscordEventLogFactory.php new file mode 100644 index 000000000..c2cd4f7ed --- /dev/null +++ b/app-modules/integration-discord/database/factories/DiscordEventLogFactory.php @@ -0,0 +1,27 @@ + + */ +final class DiscordEventLogFactory extends Factory +{ + protected $model = DiscordEventLog::class; + + public function definition(): array + { + return [ + 'event_type' => fake()->randomElement(['MESSAGE_CREATE', 'GUILD_MEMBER_ADD', 'VOICE_STATE_UPDATE', 'GUILD_BAN_ADD']), + 'guild_id' => (string) fake()->numerify('45292621755816####'), + 'user_id' => (string) fake()->numerify('26749964733605####'), + 'channel_id' => (string) fake()->numerify('55922413525165####'), + 'payload' => ['id' => fake()->uuid(), 'content' => fake()->sentence()], + ]; + } +} diff --git a/app-modules/integration-discord/database/migrations/2026_07_12_202441_add_created_at_index_to_discord_event_logs_table.php b/app-modules/integration-discord/database/migrations/2026_07_12_202441_add_created_at_index_to_discord_event_logs_table.php new file mode 100644 index 000000000..c10277cba --- /dev/null +++ b/app-modules/integration-discord/database/migrations/2026_07_12_202441_add_created_at_index_to_discord_event_logs_table.php @@ -0,0 +1,24 @@ +index('created_at'); + }); + } + + public function down(): void + { + Schema::table('discord_event_logs', function (Blueprint $table): void { + $table->dropIndex(['created_at']); + }); + } +}; diff --git a/app-modules/integration-discord/lang/en/enums.php b/app-modules/integration-discord/lang/en/enums.php new file mode 100644 index 000000000..6289c7e00 --- /dev/null +++ b/app-modules/integration-discord/lang/en/enums.php @@ -0,0 +1,112 @@ + [ + 'READY' => 'Gateway ready', + 'RESUMED' => 'Gateway resumed', + 'PRESENCE_UPDATE' => 'Presence updated', + 'PRESENCES_REPLACE' => 'Presences replaced', + 'TYPING_START' => 'Typing started', + 'USER_SETTINGS_UPDATE' => 'User settings updated', + 'GUILD_MEMBERS_CHUNK' => 'Guild members chunk received', + 'INTERACTION_CREATE' => 'Interaction created', + 'USER_UPDATE' => 'User updated', + + 'GUILD_CREATE' => 'Guild created', + 'GUILD_DELETE' => 'Guild deleted', + 'GUILD_UPDATE' => 'Guild updated', + + 'GUILD_BAN_ADD' => 'Member banned', + 'GUILD_BAN_REMOVE' => 'Member unbanned', + + 'GUILD_EMOJIS_UPDATE' => 'Emojis updated', + 'GUILD_STICKERS_UPDATE' => 'Stickers updated', + + 'GUILD_MEMBER_ADD' => 'Member joined', + 'GUILD_MEMBER_REMOVE' => 'Member left', + 'GUILD_MEMBER_UPDATE' => 'Member updated', + + 'GUILD_ROLE_CREATE' => 'Role created', + 'GUILD_ROLE_UPDATE' => 'Role updated', + 'GUILD_ROLE_DELETE' => 'Role deleted', + + 'GUILD_SCHEDULED_EVENT_CREATE' => 'Scheduled event created', + 'GUILD_SCHEDULED_EVENT_UPDATE' => 'Scheduled event updated', + 'GUILD_SCHEDULED_EVENT_DELETE' => 'Scheduled event deleted', + 'GUILD_SCHEDULED_EVENT_USER_ADD' => 'Scheduled event RSVP added', + 'GUILD_SCHEDULED_EVENT_USER_REMOVE' => 'Scheduled event RSVP removed', + + 'GUILD_SCHEDULED_EVENT_EXCEPTION_CREATE' => 'Scheduled event exception created', + 'GUILD_SCHEDULED_EVENT_EXCEPTION_UPDATE' => 'Scheduled event exception updated', + 'GUILD_SCHEDULED_EVENT_EXCEPTION_DELETE' => 'Scheduled event exception deleted', + + 'GUILD_INTEGRATIONS_UPDATE' => 'Guild integrations updated', + 'INTEGRATION_CREATE' => 'Integration created', + 'INTEGRATION_UPDATE' => 'Integration updated', + 'INTEGRATION_DELETE' => 'Integration deleted', + 'WEBHOOKS_UPDATE' => 'Webhooks updated', + 'APPLICATION_COMMAND_PERMISSIONS_UPDATE' => 'Application command permissions updated', + + 'INVITE_CREATE' => 'Invite created', + 'INVITE_DELETE' => 'Invite deleted', + + 'AUTO_MODERATION_RULE_CREATE' => 'Auto moderation rule created', + 'AUTO_MODERATION_RULE_UPDATE' => 'Auto moderation rule updated', + 'AUTO_MODERATION_RULE_DELETE' => 'Auto moderation rule deleted', + 'AUTO_MODERATION_ACTION_EXECUTION' => 'Auto moderation action executed', + + 'GUILD_AUDIT_LOG_ENTRY_CREATE' => 'Audit log entry created', + + 'GUILD_SOUNDBOARD_SOUND_CREATE' => 'Soundboard sound created', + 'GUILD_SOUNDBOARD_SOUND_UPDATE' => 'Soundboard sound updated', + 'GUILD_SOUNDBOARD_SOUND_DELETE' => 'Soundboard sound deleted', + 'GUILD_SOUNDBOARD_SOUNDS_UPDATE' => 'Soundboard sounds updated', + 'SOUNDBOARD_SOUNDS' => 'Soundboard sounds listed', + + 'CHANNEL_CREATE' => 'Channel created', + 'CHANNEL_DELETE' => 'Channel deleted', + 'CHANNEL_UPDATE' => 'Channel updated', + 'CHANNEL_PINS_UPDATE' => 'Channel pins updated', + + 'THREAD_CREATE' => 'Thread created', + 'THREAD_UPDATE' => 'Thread updated', + 'THREAD_DELETE' => 'Thread deleted', + 'THREAD_LIST_SYNC' => 'Thread list synced', + 'THREAD_MEMBER_UPDATE' => 'Thread member updated', + 'THREAD_MEMBERS_UPDATE' => 'Thread members updated', + + 'VOICE_STATE_UPDATE' => 'Voice state updated', + 'VOICE_SERVER_UPDATE' => 'Voice server updated', + 'CHANNEL_INFO' => 'Channel info requested', + 'VOICE_CHANNEL_STATUS_UPDATE' => 'Voice channel status updated', + 'VOICE_CHANNEL_START_TIME_UPDATE' => 'Voice channel start time updated', + + 'STAGE_INSTANCE_CREATE' => 'Stage instance created', + 'STAGE_INSTANCE_UPDATE' => 'Stage instance updated', + 'STAGE_INSTANCE_DELETE' => 'Stage instance deleted', + + 'MESSAGE_CREATE' => 'Message sent', + 'MESSAGE_DELETE' => 'Message deleted', + 'MESSAGE_UPDATE' => 'Message edited', + 'MESSAGE_DELETE_BULK' => 'Messages bulk deleted', + 'MESSAGE_REACTION_ADD' => 'Reaction added', + 'MESSAGE_REACTION_REMOVE' => 'Reaction removed', + 'MESSAGE_REACTION_REMOVE_ALL' => 'All reactions removed', + 'MESSAGE_REACTION_REMOVE_EMOJI' => 'Reaction emoji removed', + 'MESSAGE_POLL_VOTE_ADD' => 'Poll vote added', + 'MESSAGE_POLL_VOTE_REMOVE' => 'Poll vote removed', + + 'ENTITLEMENT_CREATE' => 'Entitlement created', + 'ENTITLEMENT_UPDATE' => 'Entitlement updated', + 'ENTITLEMENT_DELETE' => 'Entitlement deleted', + + 'SUBSCRIPTION_CREATE' => 'Subscription created', + 'SUBSCRIPTION_UPDATE' => 'Subscription updated', + 'SUBSCRIPTION_DELETE' => 'Subscription deleted', + + 'GAME_SERVER_UPDATE' => 'Game server updated', + 'GAME_SERVER_DELETE' => 'Game server deleted', + ], +]; diff --git a/app-modules/integration-discord/lang/pt_BR/enums.php b/app-modules/integration-discord/lang/pt_BR/enums.php new file mode 100644 index 000000000..089252e25 --- /dev/null +++ b/app-modules/integration-discord/lang/pt_BR/enums.php @@ -0,0 +1,112 @@ + [ + 'READY' => 'Gateway pronto', + 'RESUMED' => 'Gateway retomado', + 'PRESENCE_UPDATE' => 'Presença atualizada', + 'PRESENCES_REPLACE' => 'Presenças substituídas', + 'TYPING_START' => 'Digitação iniciada', + 'USER_SETTINGS_UPDATE' => 'Configurações do usuário atualizadas', + 'GUILD_MEMBERS_CHUNK' => 'Lote de membros recebido', + 'INTERACTION_CREATE' => 'Interação criada', + 'USER_UPDATE' => 'Usuário atualizado', + + 'GUILD_CREATE' => 'Servidor criado', + 'GUILD_DELETE' => 'Servidor removido', + 'GUILD_UPDATE' => 'Servidor atualizado', + + 'GUILD_BAN_ADD' => 'Membro banido', + 'GUILD_BAN_REMOVE' => 'Banimento removido', + + 'GUILD_EMOJIS_UPDATE' => 'Emojis atualizados', + 'GUILD_STICKERS_UPDATE' => 'Figurinhas atualizadas', + + 'GUILD_MEMBER_ADD' => 'Membro entrou', + 'GUILD_MEMBER_REMOVE' => 'Membro saiu', + 'GUILD_MEMBER_UPDATE' => 'Membro atualizado', + + 'GUILD_ROLE_CREATE' => 'Cargo criado', + 'GUILD_ROLE_UPDATE' => 'Cargo atualizado', + 'GUILD_ROLE_DELETE' => 'Cargo removido', + + 'GUILD_SCHEDULED_EVENT_CREATE' => 'Evento agendado criado', + 'GUILD_SCHEDULED_EVENT_UPDATE' => 'Evento agendado atualizado', + 'GUILD_SCHEDULED_EVENT_DELETE' => 'Evento agendado removido', + 'GUILD_SCHEDULED_EVENT_USER_ADD' => 'Confirmação de presença adicionada', + 'GUILD_SCHEDULED_EVENT_USER_REMOVE' => 'Confirmação de presença removida', + + 'GUILD_SCHEDULED_EVENT_EXCEPTION_CREATE' => 'Exceção de evento agendado criada', + 'GUILD_SCHEDULED_EVENT_EXCEPTION_UPDATE' => 'Exceção de evento agendado atualizada', + 'GUILD_SCHEDULED_EVENT_EXCEPTION_DELETE' => 'Exceção de evento agendado removida', + + 'GUILD_INTEGRATIONS_UPDATE' => 'Integrações do servidor atualizadas', + 'INTEGRATION_CREATE' => 'Integração criada', + 'INTEGRATION_UPDATE' => 'Integração atualizada', + 'INTEGRATION_DELETE' => 'Integração removida', + 'WEBHOOKS_UPDATE' => 'Webhooks atualizados', + 'APPLICATION_COMMAND_PERMISSIONS_UPDATE' => 'Permissões de comando atualizadas', + + 'INVITE_CREATE' => 'Convite criado', + 'INVITE_DELETE' => 'Convite removido', + + 'AUTO_MODERATION_RULE_CREATE' => 'Regra de automoderação criada', + 'AUTO_MODERATION_RULE_UPDATE' => 'Regra de automoderação atualizada', + 'AUTO_MODERATION_RULE_DELETE' => 'Regra de automoderação removida', + 'AUTO_MODERATION_ACTION_EXECUTION' => 'Ação de automoderação executada', + + 'GUILD_AUDIT_LOG_ENTRY_CREATE' => 'Entrada de auditoria criada', + + 'GUILD_SOUNDBOARD_SOUND_CREATE' => 'Som do soundboard criado', + 'GUILD_SOUNDBOARD_SOUND_UPDATE' => 'Som do soundboard atualizado', + 'GUILD_SOUNDBOARD_SOUND_DELETE' => 'Som do soundboard removido', + 'GUILD_SOUNDBOARD_SOUNDS_UPDATE' => 'Sons do soundboard atualizados', + 'SOUNDBOARD_SOUNDS' => 'Sons do soundboard listados', + + 'CHANNEL_CREATE' => 'Canal criado', + 'CHANNEL_DELETE' => 'Canal removido', + 'CHANNEL_UPDATE' => 'Canal atualizado', + 'CHANNEL_PINS_UPDATE' => 'Fixados do canal atualizados', + + 'THREAD_CREATE' => 'Thread criada', + 'THREAD_UPDATE' => 'Thread atualizada', + 'THREAD_DELETE' => 'Thread removida', + 'THREAD_LIST_SYNC' => 'Lista de threads sincronizada', + 'THREAD_MEMBER_UPDATE' => 'Membro da thread atualizado', + 'THREAD_MEMBERS_UPDATE' => 'Membros da thread atualizados', + + 'VOICE_STATE_UPDATE' => 'Estado de voz atualizado', + 'VOICE_SERVER_UPDATE' => 'Servidor de voz atualizado', + 'CHANNEL_INFO' => 'Informações do canal solicitadas', + 'VOICE_CHANNEL_STATUS_UPDATE' => 'Status do canal de voz atualizado', + 'VOICE_CHANNEL_START_TIME_UPDATE' => 'Horário de início do canal de voz atualizado', + + 'STAGE_INSTANCE_CREATE' => 'Palco criado', + 'STAGE_INSTANCE_UPDATE' => 'Palco atualizado', + 'STAGE_INSTANCE_DELETE' => 'Palco removido', + + 'MESSAGE_CREATE' => 'Mensagem enviada', + 'MESSAGE_DELETE' => 'Mensagem removida', + 'MESSAGE_UPDATE' => 'Mensagem editada', + 'MESSAGE_DELETE_BULK' => 'Mensagens removidas em lote', + 'MESSAGE_REACTION_ADD' => 'Reação adicionada', + 'MESSAGE_REACTION_REMOVE' => 'Reação removida', + 'MESSAGE_REACTION_REMOVE_ALL' => 'Todas as reações removidas', + 'MESSAGE_REACTION_REMOVE_EMOJI' => 'Emoji de reação removido', + 'MESSAGE_POLL_VOTE_ADD' => 'Voto em enquete adicionado', + 'MESSAGE_POLL_VOTE_REMOVE' => 'Voto em enquete removido', + + 'ENTITLEMENT_CREATE' => 'Direito criado', + 'ENTITLEMENT_UPDATE' => 'Direito atualizado', + 'ENTITLEMENT_DELETE' => 'Direito removido', + + 'SUBSCRIPTION_CREATE' => 'Assinatura criada', + 'SUBSCRIPTION_UPDATE' => 'Assinatura atualizada', + 'SUBSCRIPTION_DELETE' => 'Assinatura removida', + + 'GAME_SERVER_UPDATE' => 'Servidor de jogo atualizado', + 'GAME_SERVER_DELETE' => 'Servidor de jogo removido', + ], +]; diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php index 62e1c8d10..7c4c95e9b 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php @@ -31,17 +31,16 @@ final class ImportDiscordMessageAction /** * @param array $replyCache provider_message_id => message uuid */ - public function handle(DiscordMessageDTO $dto, string $tenantId, ?string $cachedIdentityId = null, array $replyCache = []): Message + public function handle(DiscordMessageDTO $dto, ?string $cachedIdentityId = null, array $replyCache = []): Message { - $identityId = $cachedIdentityId ?? $this->resolveAuthorIdentity($dto, $tenantId)->id; + $identityId = $cachedIdentityId ?? $this->resolveAuthorIdentity($dto)->id; $adapter = IdentityProvider::Discord->getMessageAdapter(); $payload = $dto->toDatabase([ - 'tenant_id' => $tenantId, 'external_identity_id' => $identityId, 'obtained_experience' => 0, ...$this->extractProviderSignals($dto, $adapter), - 'reply_to_message_id' => $this->resolveReplyTargetId($dto, $adapter, $replyCache, $tenantId), + 'reply_to_message_id' => $this->resolveReplyTargetId($dto, $adapter, $replyCache, allowDbLookup: true), ]); $providerMessageId = $payload['provider_message_id'] ?? null; @@ -49,18 +48,17 @@ public function handle(DiscordMessageDTO $dto, string $tenantId, ?string $cached $message = Message::query()->updateOrCreate( [ - 'tenant_id' => $tenantId, 'provider_message_id' => $providerMessageId, ], $payload, ); if ($adapter instanceof MessageActivityAdapter) { - $this->syncMentions($message, $dto, $tenantId, $adapter); - $this->syncThread($message, $dto, $tenantId, $adapter); - $this->syncAttachments($message, $dto, $tenantId, $adapter); - $this->syncEmbeds($message, $dto, $tenantId, $adapter); - $this->syncMembershipEvent($message, $dto, $tenantId, $adapter); + $this->syncMentions($message, $dto, $adapter); + $this->syncThread($message, $dto, $adapter); + $this->syncAttachments($message, $dto, $adapter); + $this->syncEmbeds($message, $dto, $adapter); + $this->syncMembershipEvent($message, $dto, $adapter); } return $message; @@ -72,7 +70,7 @@ public function handle(DiscordMessageDTO $dto, string $tenantId, ?string $cached * @param array $replyCache * @return array discordMessageId => Message stub */ - public function handleBatch(array $dtos, string $tenantId, array $identityCache, array $replyCache): array + public function handleBatch(array $dtos, array $identityCache, array $replyCache): array { $adapter = IdentityProvider::Discord->getMessageAdapter(); $now = now(); @@ -82,11 +80,10 @@ public function handleBatch(array $dtos, string $tenantId, array $identityCache, foreach ($dtos as $dto) { $id = (string) Uuid::uuid4(); - $identityId = $identityCache[$dto->authorDiscordId] ?? $this->resolveAuthorIdentity($dto, $tenantId)->id; + $identityId = $identityCache[$dto->authorDiscordId] ?? $this->resolveAuthorIdentity($dto)->id; $row = $dto->toDatabase([ 'id' => $id, - 'tenant_id' => $tenantId, 'external_identity_id' => $identityId, 'obtained_experience' => 0, ...$this->extractProviderSignals($dto, $adapter), @@ -117,11 +114,11 @@ public function handleBatch(array $dtos, string $tenantId, array $identityCache, if ($adapter instanceof MessageActivityAdapter) { foreach ($dtos as $dto) { $stub = $stubs[$dto->discordMessageId]; - $this->syncMentions($stub, $dto, $tenantId, $adapter); - $this->syncThread($stub, $dto, $tenantId, $adapter); - $this->syncAttachments($stub, $dto, $tenantId, $adapter); - $this->syncEmbeds($stub, $dto, $tenantId, $adapter); - $this->syncMembershipEvent($stub, $dto, $tenantId, $adapter); + $this->syncMentions($stub, $dto, $adapter); + $this->syncThread($stub, $dto, $adapter); + $this->syncAttachments($stub, $dto, $adapter); + $this->syncEmbeds($stub, $dto, $adapter); + $this->syncMembershipEvent($stub, $dto, $adapter); } } @@ -136,7 +133,7 @@ public function handleBatch(array $dtos, string $tenantId, array $identityCache, * @param array $existingCache * @return array */ - public function prewarm(iterable $dtos, string $tenantId, array $existingCache = []): array + public function prewarm(iterable $dtos, array $existingCache = []): array { $newAuthors = []; foreach ($dtos as $dto) { @@ -158,7 +155,6 @@ public function prewarm(iterable $dtos, string $tenantId, array $existingCache = /** @var array $existing */ $existing = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) - ->where('tenant_id', $tenantId) ->whereIn('external_account_id', array_keys($newAuthors)) ->pluck('id', 'external_account_id') ->all(); @@ -170,7 +166,7 @@ public function prewarm(iterable $dtos, string $tenantId, array $existingCache = continue; } - $cache[$discordId] = $this->createIdentity($dto, $tenantId)->id; + $cache[$discordId] = $this->createIdentity($dto)->id; } return $cache; @@ -180,7 +176,7 @@ public function prewarm(iterable $dtos, string $tenantId, array $existingCache = * @param iterable $dtos * @return array reply_to_provider_message_id => message uuid */ - public function prewarmReplyTargets(iterable $dtos, string $tenantId): array + public function prewarmReplyTargets(iterable $dtos): array { $adapter = IdentityProvider::Discord->getMessageAdapter(); if (!$adapter instanceof MessageActivityAdapter) { @@ -201,7 +197,6 @@ public function prewarmReplyTargets(iterable $dtos, string $tenantId): array /** @var array */ return Message::query() - ->where('tenant_id', $tenantId) ->whereIn('provider_message_id', array_keys($replyProviderIds)) ->pluck('id', 'provider_message_id') ->all(); @@ -239,7 +234,7 @@ private function resolveReplyTargetId( DiscordMessageDTO $dto, ?MessageActivityAdapter $adapter, array $replyCache = [], - ?string $tenantId = null, + bool $allowDbLookup = false, ): ?string { if (!$adapter instanceof MessageActivityAdapter) { return null; @@ -254,12 +249,11 @@ private function resolveReplyTargetId( return $replyCache[$reply->replyToProviderMessageId]; } - if ($tenantId === null) { + if (!$allowDbLookup) { return null; } return Message::query() - ->where('tenant_id', $tenantId) ->where('provider_message_id', $reply->replyToProviderMessageId) ->value('id'); } @@ -267,7 +261,6 @@ private function resolveReplyTargetId( private function syncMentions( Message $message, DiscordMessageDTO $dto, - string $tenantId, MessageActivityAdapter $adapter, ): void { $mentions = $adapter->extractMentions($dto->metadata); @@ -282,7 +275,6 @@ private function syncMentions( $identityMap = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) - ->where('tenant_id', $tenantId) ->whereIn('external_account_id', $providerIds) ->pluck('id', 'external_account_id') ->all(); @@ -294,7 +286,6 @@ private function syncMentions( 'mentioned_provider_account_id' => $mention->mentionedProviderAccountId, ], [ - 'tenant_id' => $tenantId, 'mentioned_identity_id' => $identityMap[$mention->mentionedProviderAccountId] ?? null, 'mentioned_username' => $mention->mentionedUsername, 'position' => $mention->position, @@ -306,7 +297,6 @@ private function syncMentions( private function syncThread( Message $message, DiscordMessageDTO $dto, - string $tenantId, MessageActivityAdapter $adapter, ): void { $thread = $adapter->extractThread($dto->metadata); @@ -316,7 +306,6 @@ private function syncThread( MessageThread::query()->updateOrCreate( [ - 'tenant_id' => $tenantId, 'provider_thread_id' => $thread->providerThreadId, ], [ @@ -331,7 +320,6 @@ private function syncThread( private function syncAttachments( Message $message, DiscordMessageDTO $dto, - string $tenantId, MessageActivityAdapter $adapter, ): void { $attachments = $adapter->extractAttachments($dto->metadata); @@ -353,7 +341,6 @@ private function syncAttachments( foreach ($attachments as $index => $attachment) { $rows[] = [ 'id' => (string) Uuid::uuid4(), - 'tenant_id' => $tenantId, 'message_id' => $message->id, 'provider_attachment_id' => $providerIds[$index] ?? null, 'url' => $attachment->url, @@ -373,7 +360,6 @@ private function syncAttachments( private function syncEmbeds( Message $message, DiscordMessageDTO $dto, - string $tenantId, MessageActivityAdapter $adapter, ): void { $embeds = $adapter->extractEmbeds($dto->metadata); @@ -389,7 +375,6 @@ private function syncEmbeds( foreach ($embeds as $index => $embed) { $rows[] = [ 'id' => (string) Uuid::uuid4(), - 'tenant_id' => $tenantId, 'message_id' => $message->id, 'url' => $embed->url, 'title' => $embed->title, @@ -410,7 +395,6 @@ private function syncEmbeds( private function syncMembershipEvent( Message $message, DiscordMessageDTO $dto, - string $tenantId, MessageActivityAdapter $adapter, ): void { $event = $adapter->extractMembershipEvent($dto->metadata); @@ -420,7 +404,6 @@ private function syncMembershipEvent( MembershipEvent::query()->updateOrCreate( [ - 'tenant_id' => $tenantId, 'provider_message_id' => $message->provider_message_id, ], [ @@ -432,30 +415,27 @@ private function syncMembershipEvent( ); } - private function resolveAuthorIdentity(DiscordMessageDTO $dto, string $tenantId): ExternalIdentity + private function resolveAuthorIdentity(DiscordMessageDTO $dto): ExternalIdentity { $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('external_account_id', $dto->authorDiscordId) - ->where('tenant_id', $tenantId) ->first(); if ($identity) { return $identity; } - return $this->createIdentity($dto, $tenantId); + return $this->createIdentity($dto); } - private function createIdentity(DiscordMessageDTO $dto, string $tenantId): ExternalIdentity + private function createIdentity(DiscordMessageDTO $dto): ExternalIdentity { $user = $this->resolveOrCreateUser($dto); - $user->tenants()->syncWithoutDetaching([$tenantId]); return ExternalIdentity::query()->create([ 'provider' => IdentityProvider::Discord, 'external_account_id' => $dto->authorDiscordId, - 'tenant_id' => $tenantId, 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'type' => IdentityProvider::Discord->getType(), diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php index b646ddfc7..92ba8edc0 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php @@ -13,17 +13,15 @@ final class ImportDiscordModerationEventAction { public function handle( DiscordModerationEventDTO $dto, - string $tenantId, ?string $sourceMessageId = null, ): ModerationEvent { - $subjectIdentity = $this->resolveSubject($dto, $tenantId); - $moderatorIdentity = $this->resolveModerator($dto, $tenantId); - $botIdentity = $this->resolveBot($dto, $tenantId); + $subjectIdentity = $this->resolveSubject($dto); + $moderatorIdentity = $this->resolveModerator($dto); + $botIdentity = $this->resolveBot($dto); $providerMessageId = isset($dto->metadata['id']) ? (string) $dto->metadata['id'] : null; $attributes = $dto->toDatabase([ - 'tenant_id' => $tenantId, 'external_identity_id' => $subjectIdentity?->id, 'moderator_identity_id' => $moderatorIdentity?->id, 'source_identity_id' => $botIdentity?->id, @@ -34,7 +32,6 @@ public function handle( if ($providerMessageId !== null) { return ModerationEvent::query()->updateOrCreate( [ - 'tenant_id' => $tenantId, 'provider_message_id' => $providerMessageId, ], $attributes, @@ -51,29 +48,26 @@ public function handle( return ModerationEvent::query()->create($attributes); } - private function resolveBot(DiscordModerationEventDTO $dto, string $tenantId): ?ExternalIdentity + private function resolveBot(DiscordModerationEventDTO $dto): ?ExternalIdentity { return ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('external_account_id', $dto->botDiscordId) - ->where('tenant_id', $tenantId) ->first(); } - private function resolveSubject(DiscordModerationEventDTO $dto, string $tenantId): ?ExternalIdentity + private function resolveSubject(DiscordModerationEventDTO $dto): ?ExternalIdentity { if ($dto->subjectDiscordId) { return ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('external_account_id', $dto->subjectDiscordId) - ->where('tenant_id', $tenantId) ->first(); } if ($dto->subjectUsername && $dto->subjectDiscriminator) { return ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) - ->where('tenant_id', $tenantId) ->whereJsonContains('metadata->user->username', $dto->subjectUsername) ->whereJsonContains('metadata->user->discriminator', $dto->subjectDiscriminator) ->first(); @@ -82,7 +76,7 @@ private function resolveSubject(DiscordModerationEventDTO $dto, string $tenantId return null; } - private function resolveModerator(DiscordModerationEventDTO $dto, string $tenantId): ?ExternalIdentity + private function resolveModerator(DiscordModerationEventDTO $dto): ?ExternalIdentity { if (!$dto->moderatorDiscordId) { return null; @@ -91,7 +85,6 @@ private function resolveModerator(DiscordModerationEventDTO $dto, string $tenant return ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('external_account_id', $dto->moderatorDiscordId) - ->where('tenant_id', $tenantId) ->first(); } } diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php index f3fc64b24..19d23cff1 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php @@ -17,16 +17,14 @@ final class ImportDiscordProfileAction { - public function handle(DiscordProfileDTO $dto, string $tenantId): ExternalIdentity + public function handle(DiscordProfileDTO $dto): ExternalIdentity { - $user = $this->resolveUser($dto, $tenantId); - $user->tenants()->syncWithoutDetaching([$tenantId]); + $user = $this->resolveUser($dto); $discordIdentity = ExternalIdentity::query()->updateOrCreate( [ 'provider' => IdentityProvider::Discord, 'external_account_id' => $dto->discordId, - 'tenant_id' => $tenantId, ], [ 'model_type' => (new User)->getMorphClass(), @@ -40,18 +38,17 @@ public function handle(DiscordProfileDTO $dto, string $tenantId): ExternalIdenti ); foreach ($dto->connectedAccounts as $account) { - $this->upsertConnectedAccount($account, $user, $tenantId, $dto); + $this->upsertConnectedAccount($account, $user, $dto); } return $discordIdentity; } - private function resolveUser(DiscordProfileDTO $dto, string $tenantId): User + private function resolveUser(DiscordProfileDTO $dto): User { $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('external_account_id', $dto->discordId) - ->where('tenant_id', $tenantId) ->first(); if ($identity instanceof ExternalIdentity) { @@ -106,13 +103,11 @@ private function syncUserAttributes(User $user, DiscordProfileDTO $dto): User private function upsertConnectedAccount( ConnectedAccountDTO $account, User $user, - string $tenantId, DiscordProfileDTO $dto, ): void { $existing = ExternalIdentity::query() ->where('provider', $account->provider) ->where('external_account_id', $account->externalAccountId) - ->where('tenant_id', $tenantId) ->first(); if ($existing instanceof ExternalIdentity && (string) $existing->model_id !== (string) $user->id) { @@ -132,7 +127,6 @@ private function upsertConnectedAccount( [ 'provider' => $account->provider, 'external_account_id' => $account->externalAccountId, - 'tenant_id' => $tenantId, ], [ 'model_type' => (new User)->getMorphClass(), diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php index 2e169b3b6..6395d71d3 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php @@ -14,7 +14,7 @@ final class ImportDiscordReactionsAction /** * @param list $reactions */ - public function handle(Message $message, array $reactions, string $tenantId): void + public function handle(Message $message, array $reactions): void { if ($reactions === []) { return; @@ -24,7 +24,6 @@ public function handle(Message $message, array $reactions, string $tenantId): vo $rows = array_map( static fn (DiscordMessageReactionDTO $r): array => $r->toDatabase([ 'id' => (string) Str::uuid(), - 'tenant_id' => $tenantId, 'reactable_type' => $message->getMorphClass(), 'reactable_id' => $message->id, 'created_at' => $now, diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php index e22d941bb..881eb7131 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php @@ -14,12 +14,11 @@ final class ImportDiscordVoiceLogAction /** * @param array $channelMap */ - public function handle(DiscordVoiceLogDTO $dto, string $tenantId, array $channelMap): ?Voice + public function handle(DiscordVoiceLogDTO $dto, array $channelMap): ?Voice { $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('external_account_id', $dto->userDiscordId) - ->where('tenant_id', $tenantId) ->first(); if (!$identity) { @@ -27,7 +26,6 @@ public function handle(DiscordVoiceLogDTO $dto, string $tenantId, array $channel } $attributes = $dto->toDatabase([ - 'tenant_id' => $tenantId, 'external_identity_id' => $identity->id, 'channel_name' => $channelMap[$dto->voiceChannelId] ?? $dto->voiceChannelId, ]); @@ -40,7 +38,6 @@ public function handle(DiscordVoiceLogDTO $dto, string $tenantId, array $channel return Voice::query()->updateOrCreate( [ - 'tenant_id' => $tenantId, 'provider_message_id' => $providerMessageId, ], $attributes, diff --git a/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php b/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php index 57bd7672a..b59271ac3 100644 --- a/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php @@ -31,8 +31,6 @@ public function handle(User $oldUser, User $newUser, string $targetUsername): ar $this->mergeOneToOneRelations($oldUser, $newUser); - $this->mergePivotTable('tenant_users', 'tenant_id', $oldUser, $newUser); - $newUser->delete(); if ($oldUser->username !== $targetUsername) { @@ -75,10 +73,6 @@ private function reassignFkRelations(User $old, User $new): array ->where('admin_id', $new->id) ->update(['admin_id' => $old->id]); - $stats['tenants'] = DB::table('tenants') - ->where('owner_id', $new->id) - ->update(['owner_id' => $old->id]); - $stats['feedbacks_target'] = DB::table('feedbacks') ->where('target_id', $new->id) ->update(['target_id' => $old->id]); @@ -107,23 +101,4 @@ private function mergeOneToOneRelations(User $old, User $new): void } } } - - private function mergePivotTable(string $table, string $otherKey, User $old, User $new): void - { - $oldKeys = DB::table($table) - ->where('user_id', $old->id) - ->pluck($otherKey) - ->all(); - - if ($oldKeys !== []) { - DB::table($table) - ->where('user_id', $new->id) - ->whereIn($otherKey, $oldKeys) - ->delete(); - } - - DB::table($table) - ->where('user_id', $new->id) - ->update(['user_id' => $old->id]); - } } diff --git a/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php b/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php index 26986dacc..07acaa91b 100644 --- a/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php @@ -6,7 +6,6 @@ use Carbon\CarbonImmutable; use He4rt\Activity\Voice\Models\Voice; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordVoiceLogAction; use He4rt\IntegrationDiscord\ETL\DTOs\DiscordVoiceLogDTO; use He4rt\IntegrationDiscord\Transport\DiscordConnector; @@ -18,7 +17,6 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Sleep; -use function Laravel\Prompts\error; use function Laravel\Prompts\intro; use function Laravel\Prompts\note; use function Laravel\Prompts\outro; @@ -54,26 +52,15 @@ public function handle( ): int { DB::disableQueryLog(); - $tenantSlug = (string) $this->option('tenant'); - $tenant = Tenant::query()->where('slug', $tenantSlug)->first(); - - if ($tenant === null) { - error(sprintf('Tenant "%s" not found.', $tenantSlug)); - - return self::FAILURE; - } - $channelId = (string) $this->argument('channel_id'); $botId = (string) $this->option('bot-id'); $isDryRun = (bool) $this->option('dry-run'); $since = CarbonImmutable::parse($this->option('since') ?? '2026-03-01'); $until = CarbonImmutable::parse($this->option('until') ?? 'now'); - $tenantId = $tenant->getKey(); intro(sprintf('Discord Voice Backfill%s', $isDryRun ? ' [DRY RUN]' : '')); $existingCount = Voice::query() - ->where('tenant_id', $tenantId) ->whereBetween('occurred_at', [$since, $until]) ->count(); @@ -83,7 +70,6 @@ public function handle( ['Channel', $channelId], ['Bot filter', $botId], ['Period', sprintf('%s → %s', $since->toDateString(), $until->toDateString())], - ['Tenant', sprintf('%s (ID: %d)', $tenant->name, $tenantId)], ['Existing voice events in period', number_format($existingCount)], ], ); @@ -99,7 +85,7 @@ public function handle( label: 'Fetching voice logs [starting...]', callback: function ($logger) use ( $connector, $voiceAction, $channelId, $botId, - $isDryRun, $since, $until, $tenantId, $channelMap, + $isDryRun, $since, $until, $channelMap, &$before, &$pages, &$fetched, ): void { $reachedSince = false; @@ -173,7 +159,6 @@ public function handle( $channelName = $channelMap[$voiceDto->voiceChannelId] ?? $voiceDto->voiceChannelId; $exists = Voice::query() - ->where('tenant_id', $tenantId) ->where('provider_message_id', (string) $message['id']) ->exists(); @@ -188,7 +173,7 @@ public function handle( )); } else { if (!$isDryRun) { - $voiceAction->handle($voiceDto, $tenantId, $channelMap); + $voiceAction->handle($voiceDto, $channelMap); } $this->voiceCount++; diff --git a/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php b/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php index f289935ce..34d37e366 100644 --- a/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php @@ -6,7 +6,6 @@ use He4rt\Activity\Message\Models\Message; use He4rt\Activity\Voice\Models\Voice; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordMessageAction; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordModerationEventAction; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordReactionsAction; @@ -55,14 +54,6 @@ public function handle( return self::FAILURE; } - $tenant = Tenant::query()->where('slug', 'he4rt')->first(); - - if (!$tenant) { - error('Tenant "he4rt" nao encontrado.'); - - return self::FAILURE; - } - $basePath = $this->argument('path'); if (!is_dir($basePath)) { @@ -72,7 +63,6 @@ public function handle( } $channelMap = $this->loadChannelMap($basePath); - $tenantId = $tenant->getKey(); $channelDirs = $this->getChannelDirectories($basePath); $channelDirs = $this->filterChannels($channelDirs, $this->option('channels')); @@ -86,7 +76,7 @@ public function handle( $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; $chunksPerChannel = $this->option('chunks') !== null ? (int) $this->option('chunks') : null; - info(sprintf('Importando mensagens de %d canais para tenant "%s"...', count($channelDirs), $tenant->name)); + info(sprintf('Importando mensagens de %d canais...', count($channelDirs))); if ($limit !== null) { info(sprintf('Limite total: %s mensagens.', number_format($limit))); } @@ -161,7 +151,7 @@ public function handle( /** @var list> $rawMessages */ $rawMessages = array_values(array_filter($allMessages, is_array(...))); - $messages = $this->filterNewMessages($rawMessages, $tenantId); + $messages = $this->filterNewMessages($rawMessages); $stats['skipped'] += count($allMessages) - count($messages); if ($messages !== []) { @@ -175,8 +165,8 @@ public function handle( $dtos = array_slice($dtos, 0, $remaining); } - $identityCache = $messageAction->prewarm($dtos, $tenantId, $identityCache); - $replyCache = $messageAction->prewarmReplyTargets($dtos, $tenantId); + $identityCache = $messageAction->prewarm($dtos, $identityCache); + $replyCache = $messageAction->prewarmReplyTargets($dtos); $rawById = []; foreach ($messages as $raw) { @@ -192,12 +182,12 @@ public function handle( DB::transaction(function () use ( $dtoBatch, $rawById, $messageAction, $reactionsAction, $voiceAction, $moderationAction, - $tenantId, $channelMap, $channelName, &$stats, &$errorSamples, + $channelMap, $channelName, &$stats, &$errorSamples, $identityCache, $replyCache, $statsSection, &$lastStatsRender, ): void { DB::statement('SET LOCAL synchronous_commit = off'); - $stubs = $messageAction->handleBatch($dtoBatch, $tenantId, $identityCache, $replyCache); + $stubs = $messageAction->handleBatch($dtoBatch, $identityCache, $replyCache); $stats['messages'] += count($stubs); foreach ($stubs as $providerId => $stub) { @@ -209,7 +199,7 @@ public function handle( try { $this->processSubEntities( $raw, $stub, $reactionsAction, $voiceAction, $moderationAction, - $tenantId, $channelMap, $stats, + $channelMap, $stats, ); } catch (Throwable $e) { $stats['errors']++; @@ -292,19 +282,18 @@ private function processSubEntities( ImportDiscordReactionsAction $reactionsAction, ImportDiscordVoiceLogAction $voiceAction, ImportDiscordModerationEventAction $moderationAction, - string $tenantId, array $channelMap, array &$stats, ): void { $reactions = DiscordMessageReactionDTO::fromDumpMessage($raw); if ($reactions !== []) { - $reactionsAction->handle($message, $reactions, $tenantId); + $reactionsAction->handle($message, $reactions); $stats['reactions'] += count($reactions); } $voiceDto = DiscordVoiceLogDTO::fromDump($raw); if ($voiceDto instanceof DiscordVoiceLogDTO) { - $voice = $voiceAction->handle($voiceDto, $tenantId, $channelMap); + $voice = $voiceAction->handle($voiceDto, $channelMap); if ($voice instanceof Voice) { $stats['voice']++; } @@ -312,7 +301,7 @@ private function processSubEntities( $moderationDto = DiscordModerationEventDTO::fromDump($raw); if ($moderationDto instanceof DiscordModerationEventDTO) { - $moderationAction->handle($moderationDto, $tenantId, $message->id); + $moderationAction->handle($moderationDto, $message->id); $stats['moderation']++; } } @@ -323,7 +312,7 @@ private function processSubEntities( private function assertSchema(): array { $required = [ - 'id', 'tenant_id', 'external_identity_id', 'provider_message_id', + 'id', 'external_identity_id', 'provider_message_id', 'channel_id', 'content', 'metadata', 'sent_at', 'edited_at', 'kind', 'raw_message_type', 'source_kind', 'is_pinned', 'mentions_everyone', 'mention_role_count', 'obtained_experience', @@ -385,7 +374,7 @@ private function renderBox(ConsoleSectionOutput $section, string $title, int $cu * @param list> $messages * @return list> */ - private function filterNewMessages(array $messages, string $tenantId): array + private function filterNewMessages(array $messages): array { $unique = []; foreach ($messages as $m) { @@ -399,7 +388,6 @@ private function filterNewMessages(array $messages, string $tenantId): array } $existing = Message::query() - ->where('tenant_id', $tenantId) ->whereIn('provider_message_id', array_keys($unique)) ->pluck('provider_message_id') ->flip() diff --git a/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php b/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php index 968ff572e..843ced72e 100644 --- a/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php @@ -4,7 +4,6 @@ namespace He4rt\IntegrationDiscord\ETL\Console; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordProfileAction; use He4rt\IntegrationDiscord\ETL\DTOs\DiscordProfileDTO; use Illuminate\Console\Attributes\Description; @@ -41,14 +40,6 @@ public function handle(ImportDiscordProfileAction $action): int return self::FAILURE; } - $tenant = Tenant::query()->where('slug', 'he4rt')->first(); - - if (!$tenant) { - error('Tenant "he4rt" nao encontrado.'); - - return self::FAILURE; - } - $basePath = $this->argument('path'); if (!is_dir($basePath)) { @@ -73,12 +64,11 @@ public function handle(ImportDiscordProfileAction $action): int )); } - $tenantId = $tenant->getKey(); $stats = ['created' => 0, 'skipped' => 0, 'errors' => 0]; /** @var list */ $errorSamples = []; - info(sprintf('Encontrados %d chunks para importar no tenant "%s".', count($chunks), $tenant->name)); + info(sprintf('Encontrados %d chunks para importar.', count($chunks))); $output = $this->output->getOutput(); @@ -119,7 +109,7 @@ public function handle(ImportDiscordProfileAction $action): int foreach (array_chunk($profiles, 250) as $batch) { DB::transaction(function () use ( - $batch, $action, $tenantId, $chunkName, + $batch, $action, $chunkName, &$stats, &$errorSamples, &$profileCurrent, $totalProfiles, $profileSection, $statsSection, $profileTitle, &$lastStatsRender, ): void { @@ -127,7 +117,7 @@ public function handle(ImportDiscordProfileAction $action): int $dto = DiscordProfileDTO::fromDump($profile); try { - $identity = $action->handle($dto, $tenantId); + $identity = $action->handle($dto); $identity->wasRecentlyCreated ? $stats['created']++ : $stats['skipped']++; } catch (Throwable $e) { $stats['errors']++; @@ -207,7 +197,7 @@ private function assertSchema(): array { $required = [ 'external_identities' => [ - 'id', 'tenant_id', 'provider', 'external_account_id', 'type', + 'id', 'provider', 'external_account_id', 'type', 'model_type', 'model_id', 'credentials_type', 'credentials', 'connected_at', 'metadata', 'created_at', 'updated_at', ], diff --git a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php index 4f0a63c9c..60a882ba1 100644 --- a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php @@ -6,7 +6,6 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\IntegrationDiscord\ETL\Actions\MergeDuplicateDiscordUserAction; use Illuminate\Console\Attributes\Description; @@ -168,13 +167,6 @@ private function runFromPairsFile(MergeDuplicateDiscordUserAction $merge, string private function runFromHeuristic(MergeDuplicateDiscordUserAction $merge): int { - $tenant = Tenant::query()->where('slug', $this->option('tenant'))->first(); - if (!$tenant instanceof Tenant) { - error('Tenant nao encontrado: '.$this->option('tenant')); - - return self::FAILURE; - } - $fromDate = (string) $this->option('from-date'); $dryRun = (bool) $this->option('dry-run'); $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; @@ -183,9 +175,8 @@ private function runFromHeuristic(MergeDuplicateDiscordUserAction $merge): int $stats = ['merged' => 0, 'no_match' => 0, 'conflict' => 0, 'errors' => 0]; info(sprintf( - 'Procurando dups apos %s no tenant "%s"%s (heuristic mode)...', + 'Procurando dups apos %s%s (heuristic mode)...', $fromDate, - $tenant->slug, $dryRun ? ' [DRY-RUN]' : '', )); @@ -200,7 +191,6 @@ private function runFromHeuristic(MergeDuplicateDiscordUserAction $merge): int $candidates = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) ->where('model_type', $userMorph) - ->where('tenant_id', $tenant->getKey()) ->whereExists(fn (Builder $q) => $q ->select(DB::raw(1)) ->from('users') diff --git a/app-modules/integration-discord/src/Enums/DiscordChannelType.php b/app-modules/integration-discord/src/Enums/DiscordChannelType.php index fefc10cac..74568787f 100644 --- a/app-modules/integration-discord/src/Enums/DiscordChannelType.php +++ b/app-modules/integration-discord/src/Enums/DiscordChannelType.php @@ -4,7 +4,13 @@ namespace He4rt\IntegrationDiscord\Enums; -enum DiscordChannelType: int +use Filament\Support\Contracts\HasColor; +use Filament\Support\Contracts\HasDescription; +use Filament\Support\Contracts\HasIcon; +use Filament\Support\Contracts\HasLabel; +use Filament\Support\Icons\Heroicon; + +enum DiscordChannelType: int implements HasColor, HasDescription, HasIcon, HasLabel { case GuildText = 0; case Dm = 1; @@ -18,4 +24,76 @@ enum DiscordChannelType: int case GuildStageVoice = 13; case GuildForum = 15; case GuildMedia = 16; + + public function getLabel(): string + { + return match ($this) { + self::GuildText => 'Texto', + self::Dm => 'DM', + self::GuildVoice => 'Voz', + self::GroupDm => 'DM em grupo', + self::GuildCategory => 'Categoria', + self::GuildAnnouncement => 'Anúncios', + self::AnnouncementThread => 'Thread de anúncio', + self::PublicThread => 'Thread pública', + self::PrivateThread => 'Thread privada', + self::GuildStageVoice => 'Palco', + self::GuildForum => 'Fórum', + self::GuildMedia => 'Mídia', + }; + } + + public function getColor(): string + { + return match ($this) { + self::GuildText => 'gray', + self::Dm => 'info', + self::GuildVoice => 'success', + self::GroupDm => 'info', + self::GuildCategory => 'gray', + self::GuildAnnouncement => 'warning', + self::AnnouncementThread => 'warning', + self::PublicThread => 'primary', + self::PrivateThread => 'primary', + self::GuildStageVoice => 'danger', + self::GuildForum => 'primary', + self::GuildMedia => 'info', + }; + } + + public function getDescription(): string + { + return match ($this) { + self::GuildText => 'Canal de texto padrão do servidor', + self::Dm => 'Mensagem direta entre dois usuários', + self::GuildVoice => 'Canal de voz do servidor', + self::GroupDm => 'Mensagem direta em grupo entre vários usuários', + self::GuildCategory => 'Categoria usada para agrupar outros canais', + self::GuildAnnouncement => 'Canal de anúncios que pode ser seguido por outros servidores', + self::AnnouncementThread => 'Thread criada a partir de um canal de anúncios', + self::PublicThread => 'Thread pública visível a todos os membros do canal', + self::PrivateThread => 'Thread privada visível apenas a membros convidados', + self::GuildStageVoice => 'Canal de palco para eventos de áudio ao vivo', + self::GuildForum => 'Canal de fórum organizado em posts com threads', + self::GuildMedia => 'Canal de mídia organizado em posts com anexos', + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::GuildText => Heroicon::OutlinedHashtag, + self::Dm => Heroicon::OutlinedEnvelope, + self::GuildVoice => Heroicon::OutlinedSpeakerWave, + self::GroupDm => Heroicon::OutlinedEnvelope, + self::GuildCategory => Heroicon::OutlinedFolder, + self::GuildAnnouncement => Heroicon::OutlinedMegaphone, + self::AnnouncementThread => Heroicon::OutlinedChatBubbleBottomCenterText, + self::PublicThread => Heroicon::OutlinedChatBubbleBottomCenterText, + self::PrivateThread => Heroicon::OutlinedChatBubbleBottomCenterText, + self::GuildStageVoice => Heroicon::OutlinedMicrophone, + self::GuildForum => Heroicon::OutlinedChatBubbleLeftRight, + self::GuildMedia => Heroicon::OutlinedPhoto, + }; + } } diff --git a/app-modules/integration-discord/src/Enums/DiscordEventType.php b/app-modules/integration-discord/src/Enums/DiscordEventType.php new file mode 100644 index 000000000..c7385d1d0 --- /dev/null +++ b/app-modules/integration-discord/src/Enums/DiscordEventType.php @@ -0,0 +1,345 @@ +value); + } + + public function getColor(): string + { + return match ($this) { + // Messages → info + self::MessageCreate, + self::MessageDelete, + self::MessageUpdate, + self::MessageDeleteBulk, + self::MessageReactionAdd, + self::MessageReactionRemove, + self::MessageReactionRemoveAll, + self::MessageReactionRemoveEmoji, + self::MessagePollVoteAdd, + self::MessagePollVoteRemove => 'info', + + // Guild members → success + self::GuildMemberAdd, + self::GuildMemberRemove, + self::GuildMemberUpdate => 'success', + + // Bans / auto moderation / audit log → danger + self::GuildBanAdd, + self::GuildBanRemove, + self::AutoModerationRuleCreate, + self::AutoModerationRuleUpdate, + self::AutoModerationRuleDelete, + self::AutoModerationActionExecution, + self::GuildAuditLogEntryCreate => 'danger', + + // Voice / stage → warning + self::VoiceStateUpdate, + self::VoiceServerUpdate, + self::ChannelInfo, + self::VoiceChannelStatusUpdate, + self::VoiceChannelStartTimeUpdate, + self::StageInstanceCreate, + self::StageInstanceUpdate, + self::StageInstanceDelete => 'warning', + + // Channels / threads → primary + self::ChannelCreate, + self::ChannelDelete, + self::ChannelUpdate, + self::ChannelPinsUpdate, + self::ThreadCreate, + self::ThreadUpdate, + self::ThreadDelete, + self::ThreadListSync, + self::ThreadMemberUpdate, + self::ThreadMembersUpdate => 'primary', + + // Everything else → gray + self::Ready, + self::Resumed, + self::PresenceUpdate, + self::PresencesReplace, + self::TypingStart, + self::UserSettingsUpdate, + self::GuildMembersChunk, + self::InteractionCreate, + self::UserUpdate, + self::GuildCreate, + self::GuildDelete, + self::GuildUpdate, + self::GuildEmojisUpdate, + self::GuildStickersUpdate, + self::GuildRoleCreate, + self::GuildRoleUpdate, + self::GuildRoleDelete, + self::GuildScheduledEventCreate, + self::GuildScheduledEventUpdate, + self::GuildScheduledEventDelete, + self::GuildScheduledEventUserAdd, + self::GuildScheduledEventUserRemove, + self::GuildScheduledEventExceptionCreate, + self::GuildScheduledEventExceptionUpdate, + self::GuildScheduledEventExceptionDelete, + self::GuildIntegrationsUpdate, + self::IntegrationCreate, + self::IntegrationUpdate, + self::IntegrationDelete, + self::WebhooksUpdate, + self::ApplicationCommandPermissionsUpdate, + self::InviteCreate, + self::InviteDelete, + self::GuildSoundboardSoundCreate, + self::GuildSoundboardSoundUpdate, + self::GuildSoundboardSoundDelete, + self::GuildSoundboardSoundsUpdate, + self::SoundboardSounds, + self::EntitlementCreate, + self::EntitlementUpdate, + self::EntitlementDelete, + self::SubscriptionCreate, + self::SubscriptionUpdate, + self::SubscriptionDelete, + self::GameServerUpdate, + self::GameServerDelete => 'gray', + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + // Messages → chat bubble + self::MessageCreate, + self::MessageDelete, + self::MessageUpdate, + self::MessageDeleteBulk, + self::MessagePollVoteAdd, + self::MessagePollVoteRemove => Heroicon::OutlinedChatBubbleLeft, + + // Reactions → face smile + self::MessageReactionAdd, + self::MessageReactionRemove, + self::MessageReactionRemoveAll, + self::MessageReactionRemoveEmoji => Heroicon::OutlinedFaceSmile, + + // Members → users + self::GuildMemberAdd, + self::GuildMemberRemove, + self::GuildMemberUpdate, + self::GuildMembersChunk => Heroicon::OutlinedUsers, + + // Bans / auto moderation / audit log → shield exclamation + self::GuildBanAdd, + self::GuildBanRemove, + self::AutoModerationRuleCreate, + self::AutoModerationRuleUpdate, + self::AutoModerationRuleDelete, + self::AutoModerationActionExecution, + self::GuildAuditLogEntryCreate => Heroicon::OutlinedShieldExclamation, + + // Voice / stage → speaker wave + self::VoiceStateUpdate, + self::VoiceServerUpdate, + self::ChannelInfo, + self::VoiceChannelStatusUpdate, + self::VoiceChannelStartTimeUpdate, + self::StageInstanceCreate, + self::StageInstanceUpdate, + self::StageInstanceDelete => Heroicon::OutlinedSpeakerWave, + + // Channels / threads → hashtag + self::ChannelCreate, + self::ChannelDelete, + self::ChannelUpdate, + self::ChannelPinsUpdate, + self::ThreadCreate, + self::ThreadUpdate, + self::ThreadDelete, + self::ThreadListSync, + self::ThreadMemberUpdate, + self::ThreadMembersUpdate => Heroicon::OutlinedHashtag, + + // Roles → tag + self::GuildRoleCreate, + self::GuildRoleUpdate, + self::GuildRoleDelete => Heroicon::OutlinedTag, + + // Invites → envelope + self::InviteCreate, + self::InviteDelete => Heroicon::OutlinedEnvelope, + + // Scheduled events → calendar + self::GuildScheduledEventCreate, + self::GuildScheduledEventUpdate, + self::GuildScheduledEventDelete, + self::GuildScheduledEventUserAdd, + self::GuildScheduledEventUserRemove, + self::GuildScheduledEventExceptionCreate, + self::GuildScheduledEventExceptionUpdate, + self::GuildScheduledEventExceptionDelete => Heroicon::OutlinedCalendar, + + // Generic / everything else → bolt + self::Ready, + self::Resumed, + self::PresenceUpdate, + self::PresencesReplace, + self::TypingStart, + self::UserSettingsUpdate, + self::InteractionCreate, + self::UserUpdate, + self::GuildCreate, + self::GuildDelete, + self::GuildUpdate, + self::GuildEmojisUpdate, + self::GuildStickersUpdate, + self::GuildIntegrationsUpdate, + self::IntegrationCreate, + self::IntegrationUpdate, + self::IntegrationDelete, + self::WebhooksUpdate, + self::ApplicationCommandPermissionsUpdate, + self::GuildSoundboardSoundCreate, + self::GuildSoundboardSoundUpdate, + self::GuildSoundboardSoundDelete, + self::GuildSoundboardSoundsUpdate, + self::SoundboardSounds, + self::EntitlementCreate, + self::EntitlementUpdate, + self::EntitlementDelete, + self::SubscriptionCreate, + self::SubscriptionUpdate, + self::SubscriptionDelete, + self::GameServerUpdate, + self::GameServerDelete => Heroicon::OutlinedBolt, + }; + } +} diff --git a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php index a849ce6d9..f75d4cb39 100644 --- a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php +++ b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php @@ -33,6 +33,8 @@ public function register(): void public function boot(): void { + $this->loadTranslationsFrom(__DIR__.'/../lang', 'integration-discord'); + DiscordEventLog::observe(DiscordEventLogObserver::class); if ($this->app->runningInConsole()) { diff --git a/app-modules/integration-discord/src/Models/DiscordEventLog.php b/app-modules/integration-discord/src/Models/DiscordEventLog.php index 492d03a6a..56633483c 100644 --- a/app-modules/integration-discord/src/Models/DiscordEventLog.php +++ b/app-modules/integration-discord/src/Models/DiscordEventLog.php @@ -5,6 +5,9 @@ namespace He4rt\IntegrationDiscord\Models; use Carbon\CarbonInterface; +use He4rt\IntegrationDiscord\Database\Factories\DiscordEventLogFactory; +use Illuminate\Database\Eloquent\Attributes\UseFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; /** @@ -17,8 +20,12 @@ * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at */ +#[UseFactory(factoryClass: DiscordEventLogFactory::class)] final class DiscordEventLog extends Model { + /** @use HasFactory */ + use HasFactory; + /** * @return array */ diff --git a/app-modules/integration-discord/src/Models/DiscordGuild.php b/app-modules/integration-discord/src/Models/DiscordGuild.php index e81419316..35f60c257 100644 --- a/app-modules/integration-discord/src/Models/DiscordGuild.php +++ b/app-modules/integration-discord/src/Models/DiscordGuild.php @@ -5,17 +5,14 @@ namespace He4rt\IntegrationDiscord\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationDiscord\Database\Factories\DiscordGuildFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id - * @property string|null $tenant_id * @property string $discord_guild_id * @property string $name * @property string|null $icon @@ -26,7 +23,6 @@ * @property CarbonInterface|null $synced_at * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at - * @property-read Tenant|null $tenant * @property-read Collection $channels * @property-read Collection $roles * @property-read Collection $members @@ -36,14 +32,6 @@ final class DiscordGuild extends Model /** @use HasFactory */ use HasFactory; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return HasMany */ diff --git a/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordMessageTest.php b/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordMessageTest.php index abd526dd3..7bb60731e 100644 --- a/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordMessageTest.php +++ b/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordMessageTest.php @@ -19,7 +19,6 @@ use He4rt\Identity\ExternalIdentity\Enums\CredentialsType; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordMessageAction; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordModerationEventAction; @@ -104,18 +103,16 @@ function heartdevsModerationLog(string $subjectId, string $moderatorId, string $ ]); } -function createTestIdentity(Tenant $tenant, string $discordId, string $username = 'testuser'): ExternalIdentity +function createTestIdentity(string $discordId, string $username = 'testuser'): ExternalIdentity { $user = User::query()->firstOrCreate( ['username' => $username], ['id' => Uuid::uuid4()->toString(), 'name' => $username, 'is_donator' => false], ); - $user->tenants()->syncWithoutDetaching([$tenant->getKey()]); return ExternalIdentity::query()->create([ 'provider' => IdentityProvider::Discord, 'external_account_id' => $discordId, - 'tenant_id' => $tenant->getKey(), 'model_type' => (new User)->getMorphClass(), 'model_id' => $user->id, 'type' => IdentityProvider::Discord->getType(), @@ -164,14 +161,14 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username test('DiscordMessageDTO::toDatabase maps fields and parses sent_at to Carbon', function (): void { $dto = DiscordMessageDTO::fromDump(discordMessage()); - $result = $dto->toDatabase(['tenant_id' => 1, 'obtained_experience' => 0]); + $result = $dto->toDatabase(['external_identity_id' => 1, 'obtained_experience' => 0]); expect($result['provider_message_id'])->toBe('541468820355940381') ->and($result['channel_id'])->toBe('541443469642563585') ->and($result['content'])->toBe('Olá, mundo!') ->and($result['sent_at'])->toBeInstanceOf(CarbonInterface::class) ->and($result['metadata'])->toBeArray() - ->and($result['tenant_id'])->toBe(1) + ->and($result['external_identity_id'])->toBe(1) ->and($result['obtained_experience'])->toBe(0); }); @@ -371,11 +368,10 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── ImportDiscordMessageAction Tests ───────────────────────────── test('it creates message linked to existing external identity', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $identity = createTestIdentity($tenant, '253642464697516033', 'letsch1'); + $identity = createTestIdentity('253642464697516033', 'letsch1'); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage())); expect($message)->toBeInstanceOf(Message::class) ->and($message->external_identity_id)->toBe($identity->id) @@ -383,44 +379,35 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it creates user and external identity for unknown author', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); - $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $action->handle(DiscordMessageDTO::fromDump(discordMessage())); $this->assertDatabaseHas('users', ['username' => 'letsch1']); $this->assertDatabaseHas('external_identities', [ 'provider' => 'discord', 'external_account_id' => '253642464697516033', - 'tenant_id' => $tenant->getKey(), ]); }); test('it upserts message when provider_message_id already exists', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); - $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); - $action->handle(DiscordMessageDTO::fromDump(discordMessage(['content' => 'updated'])), $tenant->getKey()); + $action->handle(DiscordMessageDTO::fromDump(discordMessage())); + $action->handle(DiscordMessageDTO::fromDump(discordMessage(['content' => 'updated']))); expect(Message::query()->count())->toBe(1) ->and(Message::query()->first()->content)->toBe('updated'); }); test('it sets obtained_experience to zero', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage())); expect($message->obtained_experience)->toBe(0); }); test('it stores projected message metadata without redundant fields', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage())); expect($message->metadata)->toBeArray() ->toHaveKey('type') @@ -433,10 +420,8 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it preserves empty arrays and null fields in metadata', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage())); expect($message->metadata) ->toHaveKey('mentions', []) @@ -447,8 +432,6 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it returns null metadata when projection leaves empty object', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); $message = $action->handle(DiscordMessageDTO::fromDump([ 'id' => '999999999999', @@ -457,14 +440,12 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username 'content' => '', 'author' => ['id' => '253642464697516033', 'username' => 'letsch1'], 'reactions' => [], - ]), $tenant->getKey()); + ])); expect($message->metadata)->toBeNull(); }); test('it persists full author raw payload into external_identity metadata', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); $action->handle(DiscordMessageDTO::fromDump(discordMessage([ 'author' => [ @@ -475,7 +456,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username 'discriminator' => '0001', 'public_flags' => 64, ], - ])), $tenant->getKey()); + ]))); $identity = ExternalIdentity::query() ->where('external_account_id', '253642464697516033') @@ -488,10 +469,8 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it parses sent_at from discord timestamp', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump(discordMessage())); expect($message->sent_at)->toBeInstanceOf(CarbonInterface::class) ->and($message->sent_at->year)->toBe(2_019); @@ -500,11 +479,10 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── ImportDiscordReactionsAction Tests ─────────────────────────── test('it persists reactions with reactable_type message and reactable_id', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $reactions = DiscordMessageReactionDTO::fromDumpMessage(discordMessage(['reactions' => [ ['emoji' => ['id' => null, 'name' => '👍'], 'count' => 3, 'count_details' => ['burst' => 0, 'normal' => 3]], @@ -512,7 +490,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]])); $reactionsAction = resolve(ImportDiscordReactionsAction::class); - $reactionsAction->handle($message, $reactions, $tenant->getKey()); + $reactionsAction->handle($message, $reactions); expect(Reaction::query()->count())->toBe(2); @@ -532,30 +510,28 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it is idempotent - re-running updates counts instead of duplicating', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $reactionsAction = resolve(ImportDiscordReactionsAction::class); $reactions1 = [new DiscordMessageReactionDTO(emojiId: null, emojiName: '👍', count: 3, countBurst: 0, countNormal: 3)]; - $reactionsAction->handle($message, $reactions1, $tenant->getKey()); + $reactionsAction->handle($message, $reactions1); $reactions2 = [new DiscordMessageReactionDTO(emojiId: null, emojiName: '👍', count: 7, countBurst: 1, countNormal: 6)]; - $reactionsAction->handle($message, $reactions2, $tenant->getKey()); + $reactionsAction->handle($message, $reactions2); expect(Reaction::query()->count())->toBe(1) ->and(Reaction::query()->first()->count)->toBe(7); }); test('it updates message reaction counters', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $reactions = [ new DiscordMessageReactionDTO(emojiId: null, emojiName: '👍', count: 3, countBurst: 0, countNormal: 3), @@ -563,7 +539,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]; $reactionsAction = resolve(ImportDiscordReactionsAction::class); - $reactionsAction->handle($message, $reactions, $tenant->getKey()); + $reactionsAction->handle($message, $reactions); $message->refresh(); @@ -572,30 +548,28 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('Message::reactions returns MorphMany relation', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $reactions = [new DiscordMessageReactionDTO(emojiId: null, emojiName: '🔥', count: 1, countBurst: 0, countNormal: 1)]; $reactionsAction = resolve(ImportDiscordReactionsAction::class); - $reactionsAction->handle($message, $reactions, $tenant->getKey()); + $reactionsAction->handle($message, $reactions); expect($message->reactions()->count())->toBe(1) ->and($message->reactions()->first()->emoji_name)->toBe('🔥'); }); test('it does nothing when reactions list is empty', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $reactionsAction = resolve(ImportDiscordReactionsAction::class); - $reactionsAction->handle($message, [], $tenant->getKey()); + $reactionsAction->handle($message, []); expect(Reaction::query()->count())->toBe(0); }); @@ -603,14 +577,13 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── ImportDiscordVoiceLogAction Tests ──────────────────────────── test('it creates voice record from dyno join log', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '529963308577456128', 'voiceuser'); + createTestIdentity('529963308577456128', 'voiceuser'); $dto = DiscordVoiceLogDTO::fromDump(dynoVoiceLog('529963308577456128', '452928009964355604', 'joined')); $channelMap = ['452928009964355604' => 'general-voice']; $action = resolve(ImportDiscordVoiceLogAction::class); - $voice = $action->handle($dto, $tenant->getKey(), $channelMap); + $voice = $action->handle($dto, $channelMap); expect($voice)->toBeInstanceOf(Voice::class) ->and($voice->state)->toBe('joined') @@ -619,36 +592,32 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it creates voice record from dyno leave log with left state', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '529963308577456128', 'voiceuser'); + createTestIdentity('529963308577456128', 'voiceuser'); $dto = DiscordVoiceLogDTO::fromDump(dynoVoiceLog('529963308577456128', '452928009964355604', 'left')); $action = resolve(ImportDiscordVoiceLogAction::class); - $voice = $action->handle($dto, $tenant->getKey(), []); + $voice = $action->handle($dto, []); expect($voice->state)->toBe('left'); }); test('it skips when user has no external identity', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $dto = DiscordVoiceLogDTO::fromDump(dynoVoiceLog('999999999', '452928009964355604')); $action = resolve(ImportDiscordVoiceLogAction::class); - $voice = $action->handle($dto, $tenant->getKey(), []); + $voice = $action->handle($dto, []); expect($voice)->toBeNull(); }); test('it resolves channel name from channel map', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '529963308577456128', 'voiceuser'); + createTestIdentity('529963308577456128', 'voiceuser'); $dto = DiscordVoiceLogDTO::fromDump(dynoVoiceLog('529963308577456128', '999')); $action = resolve(ImportDiscordVoiceLogAction::class); - $voice = $action->handle($dto, $tenant->getKey(), ['999' => 'music-channel']); + $voice = $action->handle($dto, ['999' => 'music-channel']); expect($voice->channel_name)->toBe('music-channel'); }); @@ -656,15 +625,14 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── ImportDiscordModerationEventAction Tests ───────────────────── test('it creates moderation event linked to subject identity (heartdevs)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $identity = createTestIdentity($tenant, '367487241171501076', 'punished_user'); - $bot = createTestIdentity($tenant, '123456789', 'heartdevs_bot'); + $identity = createTestIdentity('367487241171501076', 'punished_user'); + $bot = createTestIdentity('123456789', 'heartdevs_bot'); $raw = heartdevsModerationLog('367487241171501076', '237242679283548160', 'Banimento', 'motivo'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $event = $action->handle($dto, $tenant->getKey()); + $event = $action->handle($dto); expect($event)->toBeInstanceOf(ModerationEvent::class) ->and($event->type)->toBe(ModerationType::Ban) @@ -673,93 +641,84 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it creates moderation event with null subject when dyno username does not match', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $raw = dynoModerationLog('unknownuser', '9999', 'was banned'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $event = $action->handle($dto, $tenant->getKey()); + $event = $action->handle($dto); expect($event->external_identity_id)->toBeNull() ->and($event->type)->toBe(ModerationType::Ban); }); test('it stores moderator identity from heartdevs embed', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '367487241171501076', 'subject'); - $moderator = createTestIdentity($tenant, '237242679283548160', 'moderator'); + createTestIdentity('367487241171501076', 'subject'); + $moderator = createTestIdentity('237242679283548160', 'moderator'); $raw = heartdevsModerationLog('367487241171501076', '237242679283548160', 'Banimento', 'motivo'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $event = $action->handle($dto, $tenant->getKey()); + $event = $action->handle($dto); expect($event->moderator_identity_id)->toBe($moderator->id); }); test('it stores reason and occurred_at from embed', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $raw = heartdevsModerationLog('111', '222', 'Advertência', 'comportamento inadequado'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $event = $action->handle($dto, $tenant->getKey()); + $event = $action->handle($dto); expect($event->reason)->toBe('comportamento inadequado') ->and($event->occurred_at)->toBeInstanceOf(CarbonInterface::class); }); test('it links source_message_id when provided', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $raw = heartdevsModerationLog('111', '222', 'Banimento', 'motivo'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $event = $action->handle($dto, $tenant->getKey(), $message->id); + $event = $action->handle($dto, $message->id); expect($event->source_message_id)->toBe($message->id); }); test('it is idempotent when re-importing the same source message', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $msgAction = resolve(ImportDiscordMessageAction::class); - $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage()), $tenant->getKey()); + $message = $msgAction->handle(DiscordMessageDTO::fromDump(discordMessage())); $raw = heartdevsModerationLog('111', '222', 'Banimento', 'motivo'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $first = $action->handle($dto, $tenant->getKey(), $message->id); - $second = $action->handle($dto, $tenant->getKey(), $message->id); + $first = $action->handle($dto, $message->id); + $second = $action->handle($dto, $message->id); expect($first->id)->toBe($second->id) ->and(ModerationEvent::query()->where('source_message_id', $message->id)->count())->toBe(1); }); test('it disambiguates user name when author global_name collides', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); $action->handle(DiscordMessageDTO::fromDump(discordMessage([ 'id' => '900000000000000001', 'author' => ['id' => '111', 'username' => 'user_a', 'global_name' => 'Joao', 'bot' => false], - ])), $tenant->getKey()); + ]))); $action->handle(DiscordMessageDTO::fromDump(discordMessage([ 'id' => '900000000000000002', 'author' => ['id' => '222', 'username' => 'user_b', 'global_name' => 'Joao', 'bot' => false], - ])), $tenant->getKey()); + ]))); expect(User::query()->where('name', 'Joao')->count())->toBe(1) ->and(User::query()->where('username', 'user_a')->value('name'))->toBe('Joao') @@ -769,13 +728,12 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── Fase 1: canonical columns populated by adapter ─────────────── test('it populates canonical kind + raw_message_type from adapter', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage(['type' => 19, 'message_reference' => ['message_id' => 'parent-msg-1']]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); expect($message->kind)->toBe(MessageKind::Reply) ->and($message->raw_message_type)->toBe(19) @@ -783,21 +741,18 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it populates source_kind based on author flags', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $action = resolve(ImportDiscordMessageAction::class); $message = $action->handle( DiscordMessageDTO::fromDump(discordMessage(['author' => ['bot' => true]])), - $tenant->getKey(), ); expect($message->source_kind)->toBe(MessageSourceKind::Bot); }); test('it populates is_pinned, mentions_everyone and mention_role_count', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'pinned' => true, @@ -806,7 +761,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); expect($message->is_pinned)->toBeTrue() ->and($message->mentions_everyone)->toBeTrue() @@ -814,13 +769,12 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it parses edited_at into a Carbon instance', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage(['edited_timestamp' => '2024-05-01T10:00:00+00:00']); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); expect($message->edited_at)->toBeInstanceOf(CarbonInterface::class) ->and($message->edited_at->year)->toBe(2_024); @@ -836,19 +790,17 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it keeps deleted Discord users as separate rows', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $action = resolve(ImportDiscordMessageAction::class); $action->handle(DiscordMessageDTO::fromDump(discordMessage([ 'id' => '800000000000000001', 'author' => ['id' => '111', 'username' => 'Deleted User', 'global_name' => null, 'bot' => false], - ])), $tenant->getKey()); + ]))); $action->handle(DiscordMessageDTO::fromDump(discordMessage([ 'id' => '800000000000000002', 'author' => ['id' => '222', 'username' => 'Deleted User', 'global_name' => null, 'bot' => false], - ])), $tenant->getKey()); + ]))); expect(User::query()->where('username', 'deleted_user_111')->count())->toBe(1) ->and(User::query()->where('username', 'deleted_user_222')->count())->toBe(1) @@ -858,30 +810,28 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── Fase 1: Voice upsert idempotency ───────────────────────────── -test('it upserts voice events by (tenant_id, provider_message_id)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '529963308577456128', 'voiceuser'); +test('it upserts voice events by provider_message_id', function (): void { + createTestIdentity('529963308577456128', 'voiceuser'); $raw = dynoVoiceLog('529963308577456128', '452928009964355604', 'joined'); $dto = DiscordVoiceLogDTO::fromDump($raw); $action = resolve(ImportDiscordVoiceLogAction::class); - $action->handle($dto, $tenant->getKey(), []); - $action->handle($dto, $tenant->getKey(), []); + $action->handle($dto, []); + $action->handle($dto, []); expect(Voice::query()->count())->toBe(1) ->and(Voice::query()->first()->provider_message_id)->toBe($raw['id']); }); test('it preserves the Dyno log timestamp in occurred_at', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '529963308577456128', 'voiceuser'); + createTestIdentity('529963308577456128', 'voiceuser'); $raw = dynoVoiceLog('529963308577456128', '452928009964355604', 'joined'); $dto = DiscordVoiceLogDTO::fromDump($raw); $action = resolve(ImportDiscordVoiceLogAction::class); - $voice = $action->handle($dto, $tenant->getKey(), []); + $voice = $action->handle($dto, []); expect($voice->occurred_at)->toBeInstanceOf(CarbonInterface::class) ->and($voice->occurred_at->toISOString())->toStartWith('2019-02-03'); @@ -899,15 +849,13 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ->and($result['metadata']['subject_discriminator'])->toBe('1234'); }); -test('it upserts moderation events by (tenant_id, provider_message_id)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - +test('it upserts moderation events by provider_message_id', function (): void { $raw = dynoModerationLog('someuser', '1234', 'was banned'); $dto = DiscordModerationEventDTO::fromDump($raw); $action = resolve(ImportDiscordModerationEventAction::class); - $first = $action->handle($dto, $tenant->getKey()); - $second = $action->handle($dto, $tenant->getKey()); + $first = $action->handle($dto); + $second = $action->handle($dto); expect($first->id)->toBe($second->id) ->and(ModerationEvent::query()->count())->toBe(1); @@ -916,9 +864,8 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── Fase 2: mentions / threads / resolved replies ──────────────── test('it populates message_mentions with provider id, position and linked identity when known', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); - $bob = createTestIdentity($tenant, '999', 'bob'); + createTestIdentity('253642464697516033', 'letsch1'); + $bob = createTestIdentity('999', 'bob'); $raw = discordMessage([ 'id' => 'msg-with-mentions', @@ -929,7 +876,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); $mentions = MessageMention::query() ->where('message_id', $message->id) @@ -947,8 +894,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it upserts mentions idempotently on re-import', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'msg-dup-mentions', @@ -956,15 +902,14 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $action->handle(DiscordMessageDTO::fromDump($raw)); + $action->handle(DiscordMessageDTO::fromDump($raw)); expect(MessageMention::query()->count())->toBe(1); }); test('it creates a message_thread when payload has a thread block', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'msg-with-thread', @@ -976,7 +921,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); $thread = MessageThread::query()->where('message_id', $message->id)->first(); @@ -988,13 +933,11 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it resolves reply_to_message_id when parent message already exists', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $action = resolve(ImportDiscordMessageAction::class); $parent = $action->handle( DiscordMessageDTO::fromDump(discordMessage(['id' => 'parent-1'])), - $tenant->getKey(), ); $child = $action->handle( @@ -1003,7 +946,6 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username 'type' => 19, 'message_reference' => ['message_id' => 'parent-1'], ])), - $tenant->getKey(), ); expect($child->reply_to_provider_message_id)->toBe('parent-1') @@ -1011,8 +953,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it leaves reply_to_message_id null when parent is out of order', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $action = resolve(ImportDiscordMessageAction::class); $child = $action->handle( @@ -1021,7 +962,6 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username 'type' => 19, 'message_reference' => ['message_id' => 'unseen-parent'], ])), - $tenant->getKey(), ); expect($child->reply_to_provider_message_id)->toBe('unseen-parent') @@ -1031,8 +971,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username // ── Fase 3: attachments / embeds / membership events ───────────── test('it persists attachments with content type, size and dimensions', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'msg-atts', @@ -1048,7 +987,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); $attachment = MessageAttachment::query()->where('message_id', $message->id)->first(); @@ -1060,8 +999,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('attachments are replaced on re-import (no duplicates)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'msg-atts-dup', @@ -1072,15 +1010,14 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $action->handle(DiscordMessageDTO::fromDump($raw)); + $action->handle(DiscordMessageDTO::fromDump($raw)); expect(MessageAttachment::query()->count())->toBe(2); }); test('it persists embeds with derived source_domain and raw payload', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'msg-embed', @@ -1094,7 +1031,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); $embed = MessageEmbed::query()->where('message_id', $message->id)->first(); @@ -1105,8 +1042,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it derives a membership_event for type=7 user joins', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'join-1', @@ -1115,7 +1051,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); $event = MembershipEvent::query() ->where('provider_message_id', $message->provider_message_id) @@ -1127,8 +1063,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username }); test('it derives a boost_tier_2 membership_event for type=10', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'boost-t2', @@ -1137,14 +1072,13 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $action->handle(DiscordMessageDTO::fromDump($raw)); expect(MembershipEvent::query()->where('kind', 'boost_tier_2')->count())->toBe(1); }); test('membership_events are idempotent on re-import', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - createTestIdentity($tenant, '253642464697516033', 'letsch1'); + createTestIdentity('253642464697516033', 'letsch1'); $raw = discordMessage([ 'id' => 'join-dup', @@ -1153,15 +1087,13 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); - $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $action->handle(DiscordMessageDTO::fromDump($raw)); + $action->handle(DiscordMessageDTO::fromDump($raw)); expect(MembershipEvent::query()->count())->toBe(1); }); test('resolveOrCreateUser reuses existing legacy #0 user when no discord identity exists', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $existing = User::factory()->create(['username' => 'oldbie#0', 'name' => 'Oldbie']); $raw = discordMessage([ @@ -1170,7 +1102,7 @@ function createTestIdentity(Tenant $tenant, string $discordId, string $username ]); $action = resolve(ImportDiscordMessageAction::class); - $message = $action->handle(DiscordMessageDTO::fromDump($raw), $tenant->getKey()); + $message = $action->handle(DiscordMessageDTO::fromDump($raw)); $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) diff --git a/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php b/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php index 960922107..faee8af63 100644 --- a/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php +++ b/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php @@ -4,7 +4,6 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\IntegrationDiscord\ETL\Actions\ImportDiscordProfileAction; use He4rt\IntegrationDiscord\ETL\DTOs\ConnectedAccountDTO; @@ -134,12 +133,11 @@ function discordProfile(array $overrides = []): array // Action Tests test('it creates user and external identity from discord profile', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $profile = discordProfile(); $dto = DiscordProfileDTO::fromDump($profile); $action = resolve(ImportDiscordProfileAction::class); - $identity = $action->handle($dto, $tenant->getKey()); + $identity = $action->handle($dto); expect($identity)->toBeInstanceOf(ExternalIdentity::class); @@ -151,45 +149,29 @@ function discordProfile(array $overrides = []): array $this->assertDatabaseHas('external_identities', [ 'provider' => 'discord', 'external_account_id' => '49615312957476864', - 'tenant_id' => $tenant->getKey(), ]); }); test('it upserts external identity when already exists', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $profileOld = discordProfile(['user_profile' => ['bio' => 'old bio'], 'connected_accounts' => []]); - $action->handle(DiscordProfileDTO::fromDump($profileOld), $tenant->getKey()); + $action->handle(DiscordProfileDTO::fromDump($profileOld)); $profileNew = discordProfile(['user_profile' => ['bio' => 'new bio'], 'connected_accounts' => []]); - $identity = $action->handle(DiscordProfileDTO::fromDump($profileNew), $tenant->getKey()); + $identity = $action->handle(DiscordProfileDTO::fromDump($profileNew)); expect(ExternalIdentity::query()->count())->toBe(1) ->and(User::query()->where('username', '_tats')->count())->toBe(1) ->and($identity->metadata['user_profile']['bio'])->toBe('new bio'); }); -test('it attaches user to tenant', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $dto = DiscordProfileDTO::fromDump(discordProfile()); - - $action = resolve(ImportDiscordProfileAction::class); - $identity = $action->handle($dto, $tenant->getKey()); - - $this->assertDatabaseHas('tenant_users', [ - 'user_id' => $identity->model_id, - 'tenant_id' => $tenant->getKey(), - ]); -}); - test('it stores complete discord metadata in external identity', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $profile = discordProfile(); $dto = DiscordProfileDTO::fromDump($profile); $action = resolve(ImportDiscordProfileAction::class); - $identity = $action->handle($dto, $tenant->getKey()); + $identity = $action->handle($dto); $identity->refresh(); @@ -212,18 +194,16 @@ function discordProfile(array $overrides = []): array }); test('it sets connected_at to null when guild_member joined_at is absent', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $profile = discordProfile(['guild_member' => ['joined_at' => null]]); $dto = DiscordProfileDTO::fromDump($profile); $action = resolve(ImportDiscordProfileAction::class); - $identity = $action->handle($dto, $tenant->getKey()); + $identity = $action->handle($dto); expect($identity->connected_at)->toBeNull(); }); test('it creates external identities for each connected account', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $profile = discordProfile([ 'connected_accounts' => [ ['type' => 'twitch', 'id' => '81085454', 'name' => 'fewerygor', 'verified' => true], @@ -233,7 +213,7 @@ function discordProfile(array $overrides = []): array $dto = DiscordProfileDTO::fromDump($profile); $action = resolve(ImportDiscordProfileAction::class); - $action->handle($dto, $tenant->getKey()); + $action->handle($dto); expect(ExternalIdentity::query()->count())->toBe(3) ->and(ExternalIdentity::query()->where('provider', 'discord')->exists())->toBeTrue() @@ -245,7 +225,6 @@ function discordProfile(array $overrides = []): array }); test('it stores connected account metadata in external identity', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $profile = discordProfile([ 'connected_accounts' => [ [ @@ -260,7 +239,7 @@ function discordProfile(array $overrides = []): array $dto = DiscordProfileDTO::fromDump($profile); $action = resolve(ImportDiscordProfileAction::class); - $action->handle($dto, $tenant->getKey()); + $action->handle($dto); $steamIdentity = ExternalIdentity::query()->where('provider', 'steam')->first(); @@ -272,19 +251,17 @@ function discordProfile(array $overrides = []): array }); test('it handles profile with no connected accounts', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $profile = discordProfile(['connected_accounts' => []]); $dto = DiscordProfileDTO::fromDump($profile); $action = resolve(ImportDiscordProfileAction::class); - $action->handle($dto, $tenant->getKey()); + $action->handle($dto); expect(ExternalIdentity::query()->count())->toBe(1) ->and(ExternalIdentity::query()->where('provider', 'discord')->exists())->toBeTrue(); }); test('it upserts connected account external identities', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $profileOld = discordProfile([ @@ -292,7 +269,7 @@ function discordProfile(array $overrides = []): array ['type' => 'spotify', 'id' => 'user123', 'name' => 'old_name', 'verified' => false], ], ]); - $action->handle(DiscordProfileDTO::fromDump($profileOld), $tenant->getKey()); + $action->handle(DiscordProfileDTO::fromDump($profileOld)); expect(ExternalIdentity::query()->count())->toBe(2) ->and(ExternalIdentity::query()->where('provider', 'spotify')->first()->metadata['name'])->toBe('old_name'); @@ -302,7 +279,7 @@ function discordProfile(array $overrides = []): array ['type' => 'spotify', 'id' => 'user123', 'name' => 'new_name', 'verified' => true], ], ]); - $action->handle(DiscordProfileDTO::fromDump($profileNew), $tenant->getKey()); + $action->handle(DiscordProfileDTO::fromDump($profileNew)); expect(ExternalIdentity::query()->count())->toBe(2) ->and(ExternalIdentity::query()->where('provider', 'spotify')->count())->toBe(1) @@ -330,7 +307,6 @@ function discordProfile(array $overrides = []): array // Regression: identity-first lookup prevents user duplication on Discord username changes. test('it reuses existing user when discord identity already exists with legacy #0 username', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $oldUser = User::factory()->create(['username' => 'sun.dev_#0', 'name' => 'Sun']); @@ -339,7 +315,6 @@ function discordProfile(array $overrides = []): array ->create([ 'provider' => IdentityProvider::Discord, 'external_account_id' => '49615312957476864', - 'tenant_id' => $tenant->getKey(), 'model_id' => $oldUser->id, ]); @@ -348,7 +323,6 @@ function discordProfile(array $overrides = []): array 'user' => ['username' => 'sun.dev_', 'global_name' => 'Sun'], 'connected_accounts' => [], ])), - $tenant->getKey(), ); expect(User::query()->whereIn('username', ['sun.dev_', 'sun.dev_#0'])->count())->toBe(1) @@ -363,7 +337,6 @@ function discordProfile(array $overrides = []): array }); test('it updates username when discord pomelo handle changes', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $user = User::factory()->create(['username' => 'oldhandle', 'name' => 'oldhandle']); @@ -372,7 +345,6 @@ function discordProfile(array $overrides = []): array ->create([ 'provider' => IdentityProvider::Discord, 'external_account_id' => '49615312957476864', - 'tenant_id' => $tenant->getKey(), 'model_id' => $user->id, ]); @@ -381,7 +353,6 @@ function discordProfile(array $overrides = []): array 'user' => ['username' => 'newhandle', 'global_name' => 'New Display'], 'connected_accounts' => [], ])), - $tenant->getKey(), ); $user->refresh(); @@ -391,7 +362,6 @@ function discordProfile(array $overrides = []): array }); test('it does not steal connected account identity already linked to another user', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $alice = User::factory()->create(['username' => 'alice']); @@ -400,7 +370,6 @@ function discordProfile(array $overrides = []): array ->create([ 'provider' => IdentityProvider::Twitch, 'external_account_id' => '81085454', - 'tenant_id' => $tenant->getKey(), 'model_id' => $alice->id, ]); @@ -411,7 +380,6 @@ function discordProfile(array $overrides = []): array ['type' => 'twitch', 'id' => '81085454', 'name' => 'fake', 'verified' => true], ], ])), - $tenant->getKey(), ); $aliceTwitch->refresh(); @@ -420,7 +388,6 @@ function discordProfile(array $overrides = []): array }); test('it creates user when no identity and no username match exists', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $usersBefore = User::query()->count(); @@ -430,7 +397,6 @@ function discordProfile(array $overrides = []): array 'user' => ['id' => '777', 'username' => 'fresh', 'global_name' => 'Fresh'], 'connected_accounts' => [], ])), - $tenant->getKey(), ); expect(User::query()->count())->toBe($usersBefore + 1) @@ -438,7 +404,6 @@ function discordProfile(array $overrides = []): array }); test('it links discord identity to portal user matching username when no identity exists yet', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $action = resolve(ImportDiscordProfileAction::class); $portalUser = User::factory()->create(['username' => 'portal_user', 'name' => 'Portal']); @@ -448,7 +413,6 @@ function discordProfile(array $overrides = []): array 'user' => ['id' => '888', 'username' => 'portal_user'], 'connected_accounts' => [], ])), - $tenant->getKey(), ); $identity = ExternalIdentity::query() diff --git a/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php b/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php index 648c8200e..fbce00adf 100644 --- a/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php +++ b/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php @@ -4,7 +4,6 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Artisan; @@ -14,7 +13,7 @@ /** * @return array|ExternalIdentity|Collection> */ -function makeImportedDup(Tenant $tenant, string $discordId, string $username, array $metadataExtras = []): array +function makeImportedDup(string $discordId, string $username, array $metadataExtras = []): array { $newUser = User::factory()->create([ 'username' => $username, @@ -27,7 +26,6 @@ function makeImportedDup(Tenant $tenant, string $discordId, string $username, ar ->create([ 'provider' => IdentityProvider::Discord, 'external_account_id' => $discordId, - 'tenant_id' => $tenant->getKey(), 'model_id' => $newUser->id, 'metadata' => $metadataExtras, ]); @@ -45,9 +43,8 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' } test('dry-run does not modify the database', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('Tats#0927'); - [$newUser] = makeImportedDup($tenant, '49615312957476864', '_tats', ['legacy_username' => 'Tats#0927']); + [$newUser] = makeImportedDup('49615312957476864', '_tats', ['legacy_username' => 'Tats#0927']); Artisan::call('discord:merge-duplicate-profiles', ['--dry-run' => true, '--from-date' => '2026-05-01']); @@ -57,9 +54,8 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('merges via metadata.legacy_username (preferred match)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('Tats#0927'); - [$newUser, $identity] = makeImportedDup($tenant, '49615312957476864', '_tats', ['legacy_username' => 'Tats#0927']); + [$newUser, $identity] = makeImportedDup('49615312957476864', '_tats', ['legacy_username' => 'Tats#0927']); Artisan::call('discord:merge-duplicate-profiles', ['--from-date' => '2026-05-01']); @@ -72,9 +68,8 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('merges via badge legacy_username description fallback', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('OldName#1234'); - [$newUser, $identity] = makeImportedDup($tenant, '111', 'newname', [ + [$newUser, $identity] = makeImportedDup('111', 'newname', [ 'badges' => [ ['id' => 'legacy_username', 'description' => 'Originally known as OldName#1234'], ], @@ -91,9 +86,8 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('merges via #0 heuristic when metadata has no legacy_username', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('xpto#0'); - [$newUser, $identity] = makeImportedDup($tenant, '222', 'xpto'); + [$newUser, $identity] = makeImportedDup('222', 'xpto'); Artisan::call('discord:merge-duplicate-profiles', ['--from-date' => '2026-05-01']); @@ -106,8 +100,7 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('skips when no orphan candidate exists (genuinely new user)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - [$newUser] = makeImportedDup($tenant, '333', 'brand_new'); + [$newUser] = makeImportedDup('333', 'brand_new'); $exitCode = Artisan::call('discord:merge-duplicate-profiles', ['--from-date' => '2026-05-01']); @@ -116,10 +109,9 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('skips when multiple orphan candidates match (conflict)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphanA = makeOrphan('ambig#0'); $orphanB = makeOrphan('ambig#9999'); - [$newUser] = makeImportedDup($tenant, '444', 'ambig', [ + [$newUser] = makeImportedDup('444', 'ambig', [ 'badges' => [ ['id' => 'legacy_username', 'description' => 'Originally known as ambig#9999'], ], @@ -133,15 +125,13 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('reassigns user_id on tables with simple FK', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('xpto#0'); - [$newUser] = makeImportedDup($tenant, '555', 'xpto'); + [$newUser] = makeImportedDup('555', 'xpto'); $charId = (string) Uuid::uuid4(); DB::table('characters')->insert([ 'id' => $charId, 'user_id' => $newUser->id, - 'tenant_id' => $tenant->getKey(), 'experience' => 100, 'reputation' => 0, 'created_at' => now(), @@ -155,32 +145,11 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' ->and((string) $row->user_id)->toBe((string) $orphan->id); }); -test('dedupes pivot rows when both users have same key', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); - $orphan = makeOrphan('xpto#0'); - [$newUser] = makeImportedDup($tenant, '666', 'xpto'); - - DB::table('tenant_users')->insert([ - ['tenant_id' => $tenant->getKey(), 'user_id' => $orphan->id, 'created_at' => now(), 'updated_at' => now()], - ['tenant_id' => $tenant->getKey(), 'user_id' => $newUser->id, 'created_at' => now(), 'updated_at' => now()], - ]); - - Artisan::call('discord:merge-duplicate-profiles', ['--from-date' => '2026-05-01']); - - $count = DB::table('tenant_users') - ->where('tenant_id', $tenant->getKey()) - ->where('user_id', $orphan->id) - ->count(); - expect($count)->toBe(1); -}); - test('merges via --pairs-file JSONL when provided (file-based strategy)', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('renamed-once'); - [$newUser, $identity] = makeImportedDup($tenant, '7777', 'renamed-twice'); + [$newUser, $identity] = makeImportedDup('7777', 'renamed-twice'); $jsonl = json_encode([ - 'tenant_id' => $tenant->getKey(), 'external_account_id' => '7777', 'old_user_id' => $orphan->id, 'old_username' => $orphan->username, @@ -204,20 +173,17 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('--pairs-file skips lines whose users no longer exist', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); $orphan = makeOrphan('valid-orphan'); - [$newUser] = makeImportedDup($tenant, '8888', 'valid-dup'); + [$newUser] = makeImportedDup('8888', 'valid-dup'); $lines = [ json_encode([ - 'tenant_id' => $tenant->getKey(), 'external_account_id' => '8888', 'old_user_id' => $orphan->id, 'new_user_id' => $newUser->id, 'new_username' => 'valid-dup', ]), json_encode([ - 'tenant_id' => $tenant->getKey(), 'external_account_id' => '0000', 'old_user_id' => '00000000-0000-0000-0000-000000000000', 'new_user_id' => '11111111-1111-1111-1111-111111111111', @@ -237,11 +203,10 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' }); test('respects --limit option', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt']); makeOrphan('a#0'); makeOrphan('b#0'); - [$dup1] = makeImportedDup($tenant, '101', 'a'); - [$dup2] = makeImportedDup($tenant, '102', 'b'); + [$dup1] = makeImportedDup('101', 'a'); + [$dup2] = makeImportedDup('102', 'b'); Artisan::call('discord:merge-duplicate-profiles', ['--from-date' => '2026-05-01', '--limit' => 1]); diff --git a/app-modules/integration-discord/tests/Unit/Enums/DiscordEventTypeTest.php b/app-modules/integration-discord/tests/Unit/Enums/DiscordEventTypeTest.php new file mode 100644 index 000000000..d77280657 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Enums/DiscordEventTypeTest.php @@ -0,0 +1,22 @@ +value; + + expect(Lang::has($key, 'en'))->toBeTrue('Missing en translation for '.$case->value) + ->and(Lang::has($key, 'pt_BR'))->toBeTrue('Missing pt_BR translation for '.$case->value); + } +}); + +test('every DiscordEventType case resolves a non-empty label', function (): void { + foreach (DiscordEventType::cases() as $case) { + expect($case->getLabel())->not->toBeEmpty() + ->and($case->getLabel())->not->toContain('discord_event_type'); + } +}); diff --git a/app-modules/integration-github/database/factories/GithubContributionFactory.php b/app-modules/integration-github/database/factories/GithubContributionFactory.php index bb34e96ec..3f7b832d5 100644 --- a/app-modules/integration-github/database/factories/GithubContributionFactory.php +++ b/app-modules/integration-github/database/factories/GithubContributionFactory.php @@ -4,7 +4,6 @@ namespace He4rt\IntegrationGithub\Database\Factories; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use Illuminate\Database\Eloquent\Factories\Factory; @@ -24,7 +23,6 @@ public function definition(): array $number = fake()->unique()->numberBetween(1, 1_000_000); return [ - 'tenant_id' => Tenant::factory(), 'repo' => 'he4rt/heartdevs.com', 'actor_login' => fake()->userName(), 'actor_id' => fake()->numberBetween(1, 9_999_999), diff --git a/app-modules/integration-github/database/factories/GithubRepositoryFactory.php b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php index 374c7d254..460b7e1eb 100644 --- a/app-modules/integration-github/database/factories/GithubRepositoryFactory.php +++ b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php @@ -4,7 +4,6 @@ namespace He4rt\IntegrationGithub\Database\Factories; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\PurposeType; use He4rt\IntegrationGithub\Models\GithubRepository; use Illuminate\Database\Eloquent\Factories\Factory; @@ -22,7 +21,6 @@ final class GithubRepositoryFactory extends Factory public function definition(): array { return [ - 'tenant_id' => Tenant::factory(), 'full_name' => 'he4rt/'.fake()->unique()->slug(2), 'enabled' => true, 'last_backfilled_at' => null, diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php index d6009c574..4924a9257 100644 --- a/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php +++ b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php @@ -12,15 +12,13 @@ public function up(): void { Schema::create('github_repositories', static function (Blueprint $table): void { $table->uuid('id')->primary(); - $table->foreignUuid('tenant_id')->constrained('tenants'); $table->string('full_name'); // owner/repo $table->boolean('enabled')->default(value: true); $table->timestampTz('last_backfilled_at')->nullable(); $table->timestampsTz(); - // A allowlist é por tenant: cada comunidade mantém a sua, e o mesmo - // repo público pode ser acompanhado por mais de uma comunidade. - $table->unique(['tenant_id', 'full_name'], 'uniq_github_repositories_tenant_repo'); + // A allowlist é global: cada repo público é acompanhado uma única vez. + $table->unique('full_name', 'uniq_github_repositories_repo'); }); } diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php index d0b03678c..c5bf52877 100644 --- a/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php +++ b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php @@ -13,7 +13,6 @@ public function up(): void { Schema::create('github_contributions', static function (Blueprint $table): void { $table->uuid('id')->primary(); - $table->foreignUuid('tenant_id')->constrained('tenants'); $table->string('repo'); $table->string('actor_login'); $table->unsignedBigInteger('actor_id')->nullable(); @@ -24,12 +23,11 @@ public function up(): void $table->jsonb('metadata')->nullable(); $table->timestampsTz(); - // Isolamento por tenant: a mesma contribuição de um repo compartilhado - // é gravada uma vez por comunidade que acompanha o repo. - $table->unique(['tenant_id', 'repo', 'type', 'external_ref'], 'uniq_github_contributions_ref'); - $table->index(['tenant_id', 'occurred_at'], 'idx_github_contributions_tenant_time'); + // Cada contribuição de um repo é gravada uma única vez. + $table->unique(['repo', 'type', 'external_ref'], 'uniq_github_contributions_ref'); + $table->index('occurred_at', 'idx_github_contributions_time'); $table->index('actor_id', 'idx_github_contributions_actor'); - $table->index(['tenant_id', 'type', 'occurred_at'], 'idx_github_contributions_type_time'); + $table->index(['type', 'occurred_at'], 'idx_github_contributions_type_time'); }); } diff --git a/app-modules/integration-github/database/migrations/2026_07_15_001019_restore_github_contributions_type_time_index.php b/app-modules/integration-github/database/migrations/2026_07_15_001019_restore_github_contributions_type_time_index.php new file mode 100644 index 000000000..f4cf9d97b --- /dev/null +++ b/app-modules/integration-github/database/migrations/2026_07_15_001019_restore_github_contributions_type_time_index.php @@ -0,0 +1,16 @@ +tenant_id; $repo = $repository->full_name; $since = $full || $repository->last_backfilled_at === null ? null : $repository->last_backfilled_at->subDay()->utc()->format('Y-m-d\TH:i:s\Z'); - $this->backfillPullRequests($tenantId, $repo, $since, $onProgress); - $this->backfillIssues($tenantId, $repo, $since, $onProgress); - $this->backfillIssueComments($tenantId, $repo, $since, $onProgress); - $this->backfillReviewComments($tenantId, $repo, $since, $onProgress); - $this->backfillCommits($tenantId, $repo, $since, $onProgress); + $this->backfillPullRequests($repo, $since, $onProgress); + $this->backfillIssues($repo, $since, $onProgress); + $this->backfillIssueComments($repo, $since, $onProgress); + $this->backfillReviewComments($repo, $since, $onProgress); + $this->backfillCommits($repo, $since, $onProgress); } /** * @param (callable(NewContributionDTO, bool): void)|null $onProgress */ - private function backfillPullRequests(string $tenantId, string $repo, ?string $since, ?callable $onProgress): void + private function backfillPullRequests(string $repo, ?string $since, ?callable $onProgress): void { $this->paginate( fn (int $page): Request => new ListPullRequests($repo, $page, self::PER_PAGE), - function (array $pr) use ($tenantId, $repo, $onProgress): void { + function (array $pr) use ($repo, $onProgress): void { $number = $this->intFrom($pr, 'number') ?? 0; /** @var array $prSize */ $prSize = (array) $this->github->send(new GetPullRequest($repo, $number))->throw()->json(); $login = $this->actorLogin($pr); $this->record(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Pr, externalRef: ContributionType::Pr->ref($number), @@ -85,7 +83,7 @@ function (array $pr) use ($tenantId, $repo, $onProgress): void { ], ), $onProgress); - $this->backfillReviews($tenantId, $repo, $number, $onProgress); + $this->backfillReviews($repo, $number, $onProgress); }, // PRs vêm ordenados por updated desc: ao cruzar o corte, todo o resto é mais // antigo — paramos de paginar (mata o N+1 de GetPullRequest + reviews). @@ -103,11 +101,11 @@ function (array $pr) use ($tenantId, $repo, $onProgress): void { /** * @param (callable(NewContributionDTO, bool): void)|null $onProgress */ - private function backfillReviews(string $tenantId, string $repo, int $number, ?callable $onProgress): void + private function backfillReviews(string $repo, int $number, ?callable $onProgress): void { $this->paginate( fn (int $page): Request => new ListPullRequestReviews($repo, $number, $page, self::PER_PAGE), - function (array $review) use ($tenantId, $repo, $number, $onProgress): void { + function (array $review) use ($repo, $number, $onProgress): void { $submittedAt = $this->stringFrom($review, 'submitted_at'); // Reviews PENDING (rascunho não enviado) não têm submitted_at — a API @@ -119,7 +117,6 @@ function (array $review) use ($tenantId, $repo, $number, $onProgress): void { $login = $this->actorLogin($review); $this->record(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Review, externalRef: ContributionType::Review->ref($this->stringFrom($review, 'id')), @@ -139,11 +136,11 @@ function (array $review) use ($tenantId, $repo, $number, $onProgress): void { /** * @param (callable(NewContributionDTO, bool): void)|null $onProgress */ - private function backfillIssues(string $tenantId, string $repo, ?string $since, ?callable $onProgress): void + private function backfillIssues(string $repo, ?string $since, ?callable $onProgress): void { $this->paginate( fn (int $page): Request => new ListIssues($repo, $page, self::PER_PAGE, $since), - function (array $issue) use ($tenantId, $repo, $onProgress): void { + function (array $issue) use ($repo, $onProgress): void { if (isset($issue['pull_request'])) { return; // o endpoint de issues também devolve PRs; estes já entram via backfillPullRequests } @@ -151,7 +148,6 @@ function (array $issue) use ($tenantId, $repo, $onProgress): void { $login = $this->actorLogin($issue); $this->record(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Issue, externalRef: ContributionType::Issue->ref($this->stringFrom($issue, 'number')), @@ -173,15 +169,14 @@ function (array $issue) use ($tenantId, $repo, $onProgress): void { /** * @param (callable(NewContributionDTO, bool): void)|null $onProgress */ - private function backfillIssueComments(string $tenantId, string $repo, ?string $since, ?callable $onProgress): void + private function backfillIssueComments(string $repo, ?string $since, ?callable $onProgress): void { $this->paginate( fn (int $page): Request => new ListIssueComments($repo, $page, self::PER_PAGE, $since), - function (array $comment) use ($tenantId, $repo, $onProgress): void { + function (array $comment) use ($repo, $onProgress): void { $login = $this->actorLogin($comment); $this->record(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Comment, externalRef: ContributionType::Comment->ref($this->stringFrom($comment, 'id')), @@ -202,15 +197,14 @@ function (array $comment) use ($tenantId, $repo, $onProgress): void { /** * @param (callable(NewContributionDTO, bool): void)|null $onProgress */ - private function backfillReviewComments(string $tenantId, string $repo, ?string $since, ?callable $onProgress): void + private function backfillReviewComments(string $repo, ?string $since, ?callable $onProgress): void { $this->paginate( fn (int $page): Request => new ListPullRequestReviewComments($repo, $page, self::PER_PAGE, $since), - function (array $comment) use ($tenantId, $repo, $onProgress): void { + function (array $comment) use ($repo, $onProgress): void { $login = $this->actorLogin($comment); $this->record(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::ReviewComment, externalRef: ContributionType::ReviewComment->ref($this->stringFrom($comment, 'id')), @@ -231,16 +225,15 @@ function (array $comment) use ($tenantId, $repo, $onProgress): void { /** * @param (callable(NewContributionDTO, bool): void)|null $onProgress */ - private function backfillCommits(string $tenantId, string $repo, ?string $since, ?callable $onProgress): void + private function backfillCommits(string $repo, ?string $since, ?callable $onProgress): void { $this->paginate( fn (int $page): Request => new ListCommits($repo, $page, self::PER_PAGE, $since), - function (array $commit) use ($tenantId, $repo, $onProgress): void { + function (array $commit) use ($repo, $onProgress): void { $login = $this->stringFrom($commit, 'author.login') ?: $this->stringFrom($commit, 'commit.author.name', 'ghost'); $this->record(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Commit, externalRef: ContributionType::Commit->ref($this->stringFrom($commit, 'sha')), diff --git a/app-modules/integration-github/src/Contributions/DTOs/NewContributionDTO.php b/app-modules/integration-github/src/Contributions/DTOs/NewContributionDTO.php index 640668005..18b0784a6 100644 --- a/app-modules/integration-github/src/Contributions/DTOs/NewContributionDTO.php +++ b/app-modules/integration-github/src/Contributions/DTOs/NewContributionDTO.php @@ -17,7 +17,6 @@ * @param array $metadata */ public function __construct( - public string $tenantId, public string $repo, public ContributionType $type, public string $externalRef, diff --git a/app-modules/integration-github/src/Contributions/RecordContribution.php b/app-modules/integration-github/src/Contributions/RecordContribution.php index def8a2e31..8c2bf16d4 100644 --- a/app-modules/integration-github/src/Contributions/RecordContribution.php +++ b/app-modules/integration-github/src/Contributions/RecordContribution.php @@ -11,7 +11,7 @@ /** * Idempotent writer for contributions, shared by backfill (bulk, silent) and * webhook ingestion (live, emits the seam event). Convergence is guaranteed by - * the unique (tenant_id, repo, type, external_ref) key. + * the unique (repo, type, external_ref) key. */ final class RecordContribution { @@ -19,7 +19,6 @@ public function execute(NewContributionDTO $contribution, bool $emit = false): G { $recorded = GithubContribution::query()->updateOrCreate( [ - 'tenant_id' => $contribution->tenantId, 'repo' => $contribution->repo, 'type' => $contribution->type, 'external_ref' => $contribution->externalRef, diff --git a/app-modules/integration-github/src/Models/GithubContribution.php b/app-modules/integration-github/src/Models/GithubContribution.php index da2d9b662..7f4234825 100644 --- a/app-modules/integration-github/src/Models/GithubContribution.php +++ b/app-modules/integration-github/src/Models/GithubContribution.php @@ -5,18 +5,15 @@ namespace He4rt\IntegrationGithub\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Database\Factories\GithubContributionFactory; use He4rt\IntegrationGithub\Enums\ContributionType; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property string $id - * @property string $tenant_id * @property string $repo * @property string $actor_login * @property int|null $actor_id @@ -35,14 +32,6 @@ final class GithubContribution extends Model use HasFactory; use HasUuids; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - protected static function newFactory(): GithubContributionFactory { return GithubContributionFactory::new(); diff --git a/app-modules/integration-github/src/Models/GithubRepository.php b/app-modules/integration-github/src/Models/GithubRepository.php index 098fb0e53..7bbd8cbeb 100644 --- a/app-modules/integration-github/src/Models/GithubRepository.php +++ b/app-modules/integration-github/src/Models/GithubRepository.php @@ -5,7 +5,6 @@ namespace He4rt\IntegrationGithub\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Database\Factories\GithubRepositoryFactory; use He4rt\IntegrationGithub\Enums\PurposeType; use Illuminate\Database\Eloquent\Attributes\Table; @@ -14,12 +13,10 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str; /** * @property string $id - * @property string $tenant_id * @property string $full_name * @property bool $enabled * @property PurposeType $purpose @@ -34,14 +31,6 @@ final class GithubRepository extends Model use HasFactory; use HasUuids; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - protected static function newFactory(): GithubRepositoryFactory { return GithubRepositoryFactory::new(); diff --git a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php index 263cf1127..1951aa286 100644 --- a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php +++ b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php @@ -34,45 +34,39 @@ public function execute(string $event, array $payload): void return; } - // Fan-out: cada comunidade que acompanha esse repo recebe a sua própria - // contribuição (isolamento por tenant). Uma entrega vira N projeções. - foreach ($this->tenantsTracking($repo) as $tenantId) { - match ($event) { - 'pull_request' => $this->pullRequest($tenantId, $repo, $payload), - 'pull_request_review' => $this->review($tenantId, $repo, $payload), - 'issues' => $this->issue($tenantId, $repo, $payload), - 'issue_comment' => $this->issueComment($tenantId, $repo, $payload), - 'pull_request_review_comment' => $this->reviewComment($tenantId, $repo, $payload), - 'push' => $this->push($tenantId, $repo, $payload), - default => null, - }; + if (!$this->isTracked($repo)) { + return; } + + match ($event) { + 'pull_request' => $this->pullRequest($repo, $payload), + 'pull_request_review' => $this->review($repo, $payload), + 'issues' => $this->issue($repo, $payload), + 'issue_comment' => $this->issueComment($repo, $payload), + 'pull_request_review_comment' => $this->reviewComment($repo, $payload), + 'push' => $this->push($repo, $payload), + default => null, + }; } - /** - * @return list - */ - private function tenantsTracking(string $repo): array + private function isTracked(string $repo): bool { - return array_values(GithubRepository::query() + return GithubRepository::query() ->enabled() ->where('full_name', $repo) ->where('purpose', PurposeType::Contributions) - ->pluck('tenant_id') - ->map(static fn (mixed $tenantId): string => (string) $tenantId) - ->all()); + ->exists(); } /** * @param array $payload */ - private function pullRequest(string $tenantId, string $repo, array $payload): void + private function pullRequest(string $repo, array $payload): void { $pr = $this->arrayFrom($payload, 'pull_request'); $login = $this->stringFrom($pr, 'user.login', 'ghost'); $this->recorder->execute(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Pr, externalRef: ContributionType::Pr->ref($this->stringFrom($pr, 'number')), @@ -96,7 +90,7 @@ private function pullRequest(string $tenantId, string $repo, array $payload): vo /** * @param array $payload */ - private function review(string $tenantId, string $repo, array $payload): void + private function review(string $repo, array $payload): void { $review = $this->arrayFrom($payload, 'review'); $submittedAt = $this->stringFrom($review, 'submitted_at'); @@ -109,7 +103,6 @@ private function review(string $tenantId, string $repo, array $payload): void $login = $this->stringFrom($review, 'user.login', 'ghost'); $this->recorder->execute(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Review, externalRef: ContributionType::Review->ref($this->stringFrom($review, 'id')), @@ -127,13 +120,12 @@ private function review(string $tenantId, string $repo, array $payload): void /** * @param array $payload */ - private function issue(string $tenantId, string $repo, array $payload): void + private function issue(string $repo, array $payload): void { $issue = $this->arrayFrom($payload, 'issue'); $login = $this->stringFrom($issue, 'user.login', 'ghost'); $this->recorder->execute(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Issue, externalRef: ContributionType::Issue->ref($this->stringFrom($issue, 'number')), @@ -153,7 +145,7 @@ private function issue(string $tenantId, string $repo, array $payload): void /** * @param array $payload */ - private function issueComment(string $tenantId, string $repo, array $payload): void + private function issueComment(string $repo, array $payload): void { $comment = $this->arrayFrom($payload, 'comment'); $login = $this->stringFrom($comment, 'user.login', 'ghost'); @@ -161,7 +153,6 @@ private function issueComment(string $tenantId, string $repo, array $payload): v $target = ($isPr ? ContributionType::Pr : ContributionType::Issue)->ref($this->stringFrom($payload, 'issue.number')); $this->recorder->execute(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Comment, externalRef: ContributionType::Comment->ref($this->stringFrom($comment, 'id')), @@ -180,13 +171,12 @@ private function issueComment(string $tenantId, string $repo, array $payload): v /** * @param array $payload */ - private function reviewComment(string $tenantId, string $repo, array $payload): void + private function reviewComment(string $repo, array $payload): void { $comment = $this->arrayFrom($payload, 'comment'); $login = $this->stringFrom($comment, 'user.login', 'ghost'); $this->recorder->execute(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::ReviewComment, externalRef: ContributionType::ReviewComment->ref($this->stringFrom($comment, 'id')), @@ -205,7 +195,7 @@ private function reviewComment(string $tenantId, string $repo, array $payload): /** * @param array $payload */ - private function push(string $tenantId, string $repo, array $payload): void + private function push(string $repo, array $payload): void { foreach ($this->arrayFrom($payload, 'commits') as $commit) { if (!is_array($commit)) { @@ -216,7 +206,6 @@ private function push(string $tenantId, string $repo, array $payload): void $login = $username !== '' ? $username : $this->stringFrom($commit, 'author.name', 'ghost'); $this->recorder->execute(new NewContributionDTO( - tenantId: $tenantId, repo: $repo, type: ContributionType::Commit, externalRef: ContributionType::Commit->ref($this->stringFrom($commit, 'id')), diff --git a/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php index c7b95b538..72bfc2cb1 100644 --- a/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php +++ b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php @@ -68,7 +68,7 @@ function backfill(GithubRepository $repo): void resolve(BackfillRepository::class)->execute($repo); } -it('faz backfill de PRs upsertando contributions com tamanho, autor e tenant', function (): void { +it('faz backfill de PRs upsertando contributions com tamanho e autor', function (): void { mockGithub([ ListPullRequests::class => MockResponse::make([prPayload(1, 'maria', 42)]), GetPullRequest::class => MockResponse::make(['additions' => 10, 'deletions' => 2, 'changed_files' => 3]), @@ -79,7 +79,6 @@ function backfill(GithubRepository $repo): void $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); expect($contribution->type)->toBe(ContributionType::Pr) - ->and($contribution->tenant_id)->toBe($this->repo->tenant_id) ->and($contribution->actor_login)->toBe('maria') ->and($contribution->actor_id)->toBe(42) ->and($contribution->repo)->toBe('he4rt/heartdevs.com') diff --git a/app-modules/integration-github/tests/Feature/GithubContributionTest.php b/app-modules/integration-github/tests/Feature/GithubContributionTest.php index 6bcfe7fc3..ff0ba85f6 100644 --- a/app-modules/integration-github/tests/Feature/GithubContributionTest.php +++ b/app-modules/integration-github/tests/Feature/GithubContributionTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use Illuminate\Database\QueryException; @@ -20,43 +19,27 @@ expect($contribution->type)->toBe(ContributionType::Pr) ->and($contribution->occurred_at)->toBeInstanceOf(CarbonInterface::class) ->and($contribution->metadata['additions'])->toBe(10) - ->and($contribution->id)->toBeString() - ->and($contribution->tenant)->toBeInstanceOf(Tenant::class); + ->and($contribution->id)->toBeString(); }); -it('impede contribuição duplicada por (tenant, repo, type, external_ref)', function (): void { - $tenant = Tenant::factory()->create(); - - GithubContribution::factory()->for($tenant)->create([ +it('impede contribuição duplicada por (repo, type, external_ref)', function (): void { + GithubContribution::factory()->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); - GithubContribution::factory()->for($tenant)->create([ + GithubContribution::factory()->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); })->throws(QueryException::class); -it('permite a mesma contribuição em tenants diferentes', function (): void { +it('permite o mesmo external_ref em repos ou tipos diferentes', function (): void { GithubContribution::factory()->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); GithubContribution::factory()->create([ - 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', - ]); - - expect(GithubContribution::query()->where('external_ref', 'pr:1')->count())->toBe(2); -}); - -it('permite o mesmo external_ref em repos ou tipos diferentes', function (): void { - $tenant = Tenant::factory()->create(); - - GithubContribution::factory()->for($tenant)->create([ - 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', - ]); - GithubContribution::factory()->for($tenant)->create([ 'repo' => 'he4rt/4noobs', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); - GithubContribution::factory()->for($tenant)->create([ + GithubContribution::factory()->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Issue, 'external_ref' => 'pr:1', ]); diff --git a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php index 94b64e2de..1b2c0601d 100644 --- a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php +++ b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Models\GithubRepository; use Illuminate\Database\QueryException; @@ -12,23 +11,13 @@ expect($repo->full_name)->toBe('he4rt/heartdevs.com') ->and($repo->enabled)->toBeTrue() ->and($repo->last_backfilled_at)->toBeNull() - ->and($repo->id)->toBeString() - ->and($repo->tenant)->toBeInstanceOf(Tenant::class); + ->and($repo->id)->toBeString(); }); -it('impede full_name duplicado no mesmo tenant', function (): void { - $tenant = Tenant::factory()->create(); - - GithubRepository::factory()->for($tenant)->create(['full_name' => 'he4rt/4noobs']); - GithubRepository::factory()->for($tenant)->create(['full_name' => 'he4rt/4noobs']); -})->throws(QueryException::class); - -it('permite o mesmo full_name em tenants diferentes', function (): void { +it('impede full_name duplicado', function (): void { GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); - - expect(GithubRepository::query()->where('full_name', 'he4rt/4noobs')->count())->toBe(2); -}); +})->throws(QueryException::class); it('o scope enabled retorna somente os repositórios habilitados', function (): void { GithubRepository::factory()->count(2)->create(); diff --git a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php index 8e38f4007..0cbafeddb 100644 --- a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php +++ b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php @@ -93,7 +93,7 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, it('grava no lake e projeta a contribuição para repo na allowlist, emitindo o evento', function (): void { Event::fake([GithubContributionRecorded::class]); - $repo = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); postGithubWebhook('pull_request', prWebhookPayload())->assertSuccessful(); @@ -102,25 +102,12 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); expect($contribution->type)->toBe(ContributionType::Pr) - ->and($contribution->tenant_id)->toBe($repo->tenant_id) ->and($contribution->actor_login)->toBe('maria') ->and($contribution->metadata['additions'])->toBe(5); Event::assertDispatched(GithubContributionRecorded::class); }); -it('faz fan-out: projeta uma contribuição por tenant que acompanha o repo', function (): void { - $a = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); - $b = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); - GithubRepository::factory()->disabled()->create(['full_name' => 'he4rt/heartdevs.com']); - - postGithubWebhook('pull_request', prWebhookPayload())->assertSuccessful(); - - expect(GithubEventLog::query()->count())->toBe(1) - ->and(GithubContribution::query()->where('external_ref', 'pr:1')->pluck('tenant_id')->sort()->values()->all()) - ->toBe(collect([$a->tenant_id, $b->tenant_id])->sort()->values()->all()); -}); - it('deduplica entregas repetidas pelo delivery id', function (): void { GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); $delivery = Str::uuid()->toString(); @@ -180,7 +167,7 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, it('grava no lake e projeta a contribuição para repo de contributions, emitindo o evento', function (): void { Event::fake([GithubContributionRecorded::class]); - $repo = GithubRepository::factory()->create([ + GithubRepository::factory()->create([ 'full_name' => 'he4rt/heartdevs.com', 'purpose' => PurposeType::Contributions, ]); @@ -192,7 +179,6 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); expect($contribution->type)->toBe(ContributionType::Pr) - ->and($contribution->tenant_id)->toBe($repo->tenant_id) ->and($contribution->actor_login)->toBe('maria') ->and($contribution->metadata['additions'])->toBe(5); diff --git a/app-modules/integration-twitch/routes/twitch-webhook-routes.php b/app-modules/integration-twitch/routes/twitch-webhook-routes.php index 75bda344c..f8004ca40 100644 --- a/app-modules/integration-twitch/routes/twitch-webhook-routes.php +++ b/app-modules/integration-twitch/routes/twitch-webhook-routes.php @@ -13,6 +13,6 @@ VerifyTwitchSignature::class, ]) ->group(static function (): void { - Route::post('/eventsub/{tenant:slug}', TwitchWebhookController::class) + Route::post('/eventsub', TwitchWebhookController::class) ->name('twitch.eventsub.webhook'); }); diff --git a/app-modules/integration-twitch/src/Actions/RegisterTwitchSubscriptionsAction.php b/app-modules/integration-twitch/src/Actions/RegisterTwitchSubscriptionsAction.php index db8f3b9e8..0a52181f0 100644 --- a/app-modules/integration-twitch/src/Actions/RegisterTwitchSubscriptionsAction.php +++ b/app-modules/integration-twitch/src/Actions/RegisterTwitchSubscriptionsAction.php @@ -4,9 +4,6 @@ namespace He4rt\IntegrationTwitch\Actions; -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationTwitch\Enums\TwitchEventSubType; use He4rt\IntegrationTwitch\Models\TwitchSubscription; use He4rt\IntegrationTwitch\Transport\Requests\EventSub\CreateSubscription; @@ -23,29 +20,17 @@ public function __construct( * @param array $types * @return array{created: int, skipped: int, failed: int, errors: array} */ - public function __invoke(Tenant $tenant, array $types = []): array + public function __invoke(string $broadcasterId, array $types = []): array { - $broadcasterId = $this->resolveBroadcasterId($tenant); - - if ($broadcasterId === null) { - return [ - 'created' => 0, - 'skipped' => 0, - 'failed' => 0, - 'errors' => ['broadcaster' => 'No Twitch channel linked to this tenant.'], - ]; - } - if ($types === []) { $types = TwitchEventSubType::cases(); } - $callbackUrl = mb_rtrim(config('app.url'), '/').'/api/webhooks/twitch/eventsub/'.$tenant->slug; + $callbackUrl = $this->resolveCallbackUrl(); $secret = config()->string('services.twitch.eventsub_secret'); $existingTypes = TwitchSubscription::query() - ->where('tenant_id', $tenant->getKey()) ->where('broadcaster_user_id', $broadcasterId) ->where('status', 'enabled') ->pluck('type') @@ -99,7 +84,6 @@ public function __invoke(Tenant $tenant, array $types = []): array 'callback_url' => $data['transport']['callback'] ?? $callbackUrl, 'cost' => $data['cost'] ?? 0, 'version' => $data['version'] ?? $type->getVersion(), - 'tenant_id' => $tenant->getKey(), ] ); @@ -118,13 +102,14 @@ public function __invoke(Tenant $tenant, array $types = []): array return ['created' => $created, 'skipped' => $skipped, 'failed' => $failed, 'errors' => $errors]; } - private function resolveBroadcasterId(Tenant $tenant): ?string + private function resolveCallbackUrl(): string { - return ExternalIdentity::query() - ->where('tenant_id', $tenant->getKey()) - ->where('provider', IdentityProvider::Twitch) - ->whereNotNull('connected_at') - ->whereNull('disconnected_at') - ->value('external_account_id'); + $configured = config()->string('services.twitch.eventsub_callback', ''); + + if ($configured !== '') { + return $configured; + } + + return mb_rtrim(config()->string('app.url'), '/').'/api/webhooks/twitch/eventsub'; } } diff --git a/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php b/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php index 8698f5967..fcb311a26 100644 --- a/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php +++ b/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php @@ -4,25 +4,25 @@ namespace He4rt\IntegrationTwitch\Console; -use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager; -use He4rt\Identity\ExternalIdentity\Enums\CredentialsType; -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Enums\IdentityType; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationTwitch\Transport\Requests\Users\GetUsers; use He4rt\IntegrationTwitch\Transport\TwitchHelixConnector; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -#[Description(description: 'Link a Twitch channel to a tenant via ExternalIdentity')] -#[Signature(signature: 'twitch:link-channel {login : Twitch channel login name} {--tenant= : Tenant slug or ID}')] +#[Description(description: 'Resolve a Twitch channel broadcaster id for use with twitch:subscribe')] +#[Signature(signature: 'twitch:link-channel {login? : Twitch channel login name (defaults to config broadcaster login)}')] final class LinkTwitchChannelCommand extends Command { public function handle(TwitchHelixConnector $helix): int { - $login = $this->argument('login'); - $tenantOption = $this->option('tenant'); + $login = $this->argument('login') ?? config()->string('services.twitch.broadcaster_login', ''); + + if ($login === '') { + $this->error('No Twitch login provided. Pass a login argument or set TWITCH_BROADCASTER_LOGIN.'); + + return self::FAILURE; + } $response = $helix->send(new GetUsers(login: $login)); $users = $response->json('data', []); @@ -37,46 +37,11 @@ public function handle(TwitchHelixConnector $helix): int $broadcasterId = $twitchUser['id']; $displayName = $twitchUser['display_name']; - $query = Tenant::query()->where('slug', $tenantOption); - - if (is_numeric($tenantOption)) { - $query->orWhere('id', $tenantOption); - } - - $tenant = $query->first(); - - if (!$tenant) { - $this->error(sprintf("Tenant '%s' not found.", $tenantOption)); - - return self::FAILURE; - } - - $existing = $tenant->providers() - ->where('provider', IdentityProvider::Twitch) - ->where('external_account_id', $broadcasterId) - ->first(); - - if ($existing) { - $this->warn(sprintf("Channel '%s' (ID: %s) is already linked to tenant '%s'.", $login, $broadcasterId, $tenant->name)); - - return self::SUCCESS; - } - - $tenant->providers()->create([ - 'tenant_id' => $tenant->getKey(), - 'type' => IdentityType::External, - 'provider' => IdentityProvider::Twitch, - 'credentials_type' => CredentialsType::OAuth2, - 'credentials' => ClientAccessManager::make(), - 'external_account_id' => $broadcasterId, - 'connected_at' => now(), - 'metadata' => [ - 'login' => $login, - 'display_name' => $displayName, - ], - ]); - - $this->info(sprintf("Linked Twitch channel '%s' (ID: %s) to tenant '%s'.", $displayName, $broadcasterId, $tenant->name)); + $this->info(sprintf("Resolved Twitch channel '%s' (login: %s).", $displayName, $login)); + $this->line(sprintf('Broadcaster ID: %s', $broadcasterId)); + $this->newLine(); + $this->comment('Register EventSub subscriptions for this channel with:'); + $this->line(sprintf(' php artisan twitch:subscribe %s --all', $broadcasterId)); return self::SUCCESS; } diff --git a/app-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.php b/app-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.php index ab0084a6a..27f2925ea 100644 --- a/app-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.php +++ b/app-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.php @@ -4,7 +4,6 @@ namespace He4rt\IntegrationTwitch\Http\Controllers; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationTwitch\Events\TwitchEventReceived; use He4rt\IntegrationTwitch\Models\TwitchEventLog; use Illuminate\Http\Request; @@ -12,7 +11,7 @@ final class TwitchWebhookController { - public function __invoke(Request $request, Tenant $tenant): Response + public function __invoke(Request $request): Response { $messageType = $request->header('Twitch-Eventsub-Message-Type'); @@ -34,7 +33,6 @@ public function __invoke(Request $request, Tenant $tenant): Response $messageId = $request->header('Twitch-Eventsub-Message-Id'); $inserted = TwitchEventLog::query()->insertOrIgnore([ - 'tenant_id' => $tenant->getKey(), 'event_type' => $subscription['type'] ?? $messageType, 'broadcaster_user_id' => $event['broadcaster_user_id'] ?? $event['to_broadcaster_user_id'] diff --git a/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php b/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php index d53955232..63047a25b 100644 --- a/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php +++ b/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php @@ -24,7 +24,7 @@ public function register(): void $this->app->singleton(TwitchAppTokenService::class); $this->app->singleton(TwitchHelixConnector::class, fn (): TwitchHelixConnector => new TwitchHelixConnector( - appToken: $this->app->make(TwitchAppTokenService::class)->getToken(), + tokenService: $this->app->make(TwitchAppTokenService::class), clientId: config()->string('services.twitch.client_id'), )); diff --git a/app-modules/integration-twitch/src/Models/TwitchEventLog.php b/app-modules/integration-twitch/src/Models/TwitchEventLog.php index 0433c9ee6..4ba68c14e 100644 --- a/app-modules/integration-twitch/src/Models/TwitchEventLog.php +++ b/app-modules/integration-twitch/src/Models/TwitchEventLog.php @@ -5,13 +5,10 @@ namespace He4rt\IntegrationTwitch\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $id - * @property string|null $tenant_id * @property string $event_type * @property string|null $broadcaster_user_id * @property string|null $user_id @@ -23,7 +20,6 @@ final class TwitchEventLog extends Model { protected $fillable = [ - 'tenant_id', 'event_type', 'broadcaster_user_id', 'user_id', @@ -31,14 +27,6 @@ final class TwitchEventLog extends Model 'payload', ]; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return array */ diff --git a/app-modules/integration-twitch/src/Models/TwitchSubscription.php b/app-modules/integration-twitch/src/Models/TwitchSubscription.php index cb30277e1..7cdc4063f 100644 --- a/app-modules/integration-twitch/src/Models/TwitchSubscription.php +++ b/app-modules/integration-twitch/src/Models/TwitchSubscription.php @@ -5,10 +5,8 @@ namespace He4rt\IntegrationTwitch\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationTwitch\Enums\TwitchSubscriptionStatus; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $id @@ -21,7 +19,6 @@ * @property string|null $callback_url * @property int $cost * @property string $version - * @property string $tenant_id * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at */ @@ -37,17 +34,8 @@ final class TwitchSubscription extends Model 'callback_url', 'cost', 'version', - 'tenant_id', ]; - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return array */ diff --git a/app-modules/integration-twitch/src/OAuth/TwitchAppTokenService.php b/app-modules/integration-twitch/src/OAuth/TwitchAppTokenService.php index ea6d0b10b..cdd4689b0 100644 --- a/app-modules/integration-twitch/src/OAuth/TwitchAppTokenService.php +++ b/app-modules/integration-twitch/src/OAuth/TwitchAppTokenService.php @@ -4,6 +4,7 @@ namespace He4rt\IntegrationTwitch\OAuth; +use He4rt\Identity\Auth\Exceptions\OAuthFlowException; use He4rt\IntegrationTwitch\Transport\Requests\OAuth\GetAppAccessToken; use He4rt\IntegrationTwitch\Transport\TwitchOAuthConnector; use Illuminate\Support\Facades\Cache; @@ -23,7 +24,16 @@ public function getToken(): string )); $accessToken = $response->json('access_token'); - $expiresIn = $response->json('expires_in'); + + $tokenRequestFailed = !is_string($accessToken) || $accessToken === ''; + + if ($tokenRequestFailed) { + $reason = $response->json('message') ?? sprintf('unexpected response (HTTP %d)', $response->status()); + + throw OAuthFlowException::tokenExchangeFailed('twitch', (string) $reason); + } + + $expiresIn = (int) ($response->json('expires_in') ?? 3_600); Cache::put('twitch_app_token_expires_in', $expiresIn, $expiresIn); diff --git a/app-modules/integration-twitch/src/Transport/TwitchHelixConnector.php b/app-modules/integration-twitch/src/Transport/TwitchHelixConnector.php index 2f0aac03c..c4db254db 100644 --- a/app-modules/integration-twitch/src/Transport/TwitchHelixConnector.php +++ b/app-modules/integration-twitch/src/Transport/TwitchHelixConnector.php @@ -4,6 +4,7 @@ namespace He4rt\IntegrationTwitch\Transport; +use He4rt\IntegrationTwitch\OAuth\TwitchAppTokenService; use Saloon\Http\Auth\TokenAuthenticator; use Saloon\Http\Connector; use Saloon\Traits\Plugins\HasTimeout; @@ -17,7 +18,7 @@ final class TwitchHelixConnector extends Connector protected int $requestTimeout = 10; public function __construct( - private readonly string $appToken, + private readonly TwitchAppTokenService $tokenService, private readonly string $clientId, ) {} @@ -28,7 +29,7 @@ public function resolveBaseUrl(): string protected function defaultAuth(): TokenAuthenticator { - return new TokenAuthenticator($this->appToken); + return new TokenAuthenticator($this->tokenService->getToken()); } /** diff --git a/app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php b/app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php index 79b655809..a56ab8596 100644 --- a/app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php +++ b/app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php @@ -2,13 +2,30 @@ declare(strict_types=1); -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\IntegrationTwitch\OAuth\TwitchAppTokenService; use He4rt\IntegrationTwitch\Transport\TwitchHelixConnector; +use He4rt\IntegrationTwitch\Transport\TwitchOAuthConnector; +use Illuminate\Support\Facades\Cache; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +function fakeTwitchHelixConnector(MockClient $mock): TwitchHelixConnector +{ + // The app token is resolved lazily by the connector; seed the cache so + // getToken() short-circuits without hitting the real Twitch OAuth endpoint. + Cache::put('twitch_app_access_token', 'fake-token', 3_600); + + return tap( + new TwitchHelixConnector( + tokenService: new TwitchAppTokenService( + new TwitchOAuthConnector(clientId: 'fake-client-id', clientSecret: 'fake-secret'), + ), + clientId: 'fake-client-id', + ), + fn (TwitchHelixConnector $connector) => $connector->withMockClient($mock), + ); +} + function mockHelixUsersResponse(string $id = '12345', string $login = 'danielhe4rt', string $displayName = 'danielhe4rt'): MockClient { $mock = new MockClient([ @@ -23,45 +40,18 @@ function mockHelixUsersResponse(string $id = '12345', string $login = 'danielhe4 ]), ]); - app()->instance(TwitchHelixConnector::class, tap( - new TwitchHelixConnector(appToken: 'fake-token', clientId: 'fake-client-id'), - fn (TwitchHelixConnector $connector) => $connector->withMockClient($mock), - )); + app()->instance(TwitchHelixConnector::class, fakeTwitchHelixConnector($mock)); return $mock; } -test('links a twitch channel to a tenant', function (): void { +test('resolves a twitch channel broadcaster id and prints the env config', function (): void { mockHelixUsersResponse(); - $tenant = Tenant::factory()->create(['slug' => 'he4rt-developers']); - $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt', '--tenant' => 'he4rt-developers']) + $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt']) + ->expectsOutputToContain('Broadcaster ID: 12345') + ->expectsOutputToContain('php artisan twitch:subscribe 12345 --all') ->assertSuccessful(); - - $identity = ExternalIdentity::query() - ->where('provider', IdentityProvider::Twitch) - ->where('external_account_id', '12345') - ->first(); - - expect($identity)->not->toBeNull() - ->and($identity->tenant_id)->toBe($tenant->id) - ->and($identity->metadata)->toMatchArray([ - 'login' => 'danielhe4rt', - 'display_name' => 'danielhe4rt', - ]); -}); - -test('warns when channel is already linked', function (): void { - mockHelixUsersResponse(); - $tenant = Tenant::factory()->create(['slug' => 'he4rt-developers']); - - $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt', '--tenant' => 'he4rt-developers']) - ->assertSuccessful(); - - $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt', '--tenant' => 'he4rt-developers']) - ->assertSuccessful(); - - expect(ExternalIdentity::query()->where('provider', IdentityProvider::Twitch)->count())->toBe(1); }); test('fails when twitch user is not found', function (): void { @@ -69,20 +59,18 @@ function mockHelixUsersResponse(string $id = '12345', string $login = 'danielhe4 '*' => MockResponse::make(['data' => []]), ]); - app()->instance(TwitchHelixConnector::class, tap( - new TwitchHelixConnector(appToken: 'fake-token', clientId: 'fake-client-id'), - fn (TwitchHelixConnector $connector) => $connector->withMockClient($mock), - )); - - Tenant::factory()->create(['slug' => 'he4rt-developers']); + app()->instance(TwitchHelixConnector::class, fakeTwitchHelixConnector($mock)); - $this->artisan('twitch:link-channel', ['login' => 'nonexistent', '--tenant' => 'he4rt-developers']) + $this->artisan('twitch:link-channel', ['login' => 'nonexistent']) ->assertFailed(); }); -test('fails when tenant is not found', function (): void { +test('fails when no login is provided and no broadcaster login is configured', function (): void { + // Bind a connector instance so resolving the command does not build the real + // one from (absent) Twitch credentials; the command fails before it ever sends. mockHelixUsersResponse(); + config()->set('services.twitch.broadcaster_login', ''); - $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt', '--tenant' => 'nonexistent']) + $this->artisan('twitch:link-channel') ->assertFailed(); }); diff --git a/app-modules/integration-twitch/tests/Feature/RegisterTwitchSubscriptionsActionTest.php b/app-modules/integration-twitch/tests/Feature/RegisterTwitchSubscriptionsActionTest.php new file mode 100644 index 000000000..cf77788e8 --- /dev/null +++ b/app-modules/integration-twitch/tests/Feature/RegisterTwitchSubscriptionsActionTest.php @@ -0,0 +1,88 @@ +instance(TwitchHelixConnector::class, tap( + new TwitchHelixConnector( + tokenService: new TwitchAppTokenService( + new TwitchOAuthConnector(clientId: 'fake-client-id', clientSecret: 'fake-secret'), + ), + clientId: 'fake-client-id', + ), + fn (TwitchHelixConnector $connector) => $connector->withMockClient($mock), + )); +} + +beforeEach(function (): void { + config()->set('services.twitch.eventsub_callback', 'https://example.com/api/webhooks/twitch/eventsub'); + config()->set('services.twitch.eventsub_secret', 'test-secret-at-least-ten-chars'); +}); + +test('registra a subscription usando o broadcaster id informado, sem tocar em config', function (): void { + bindMockedHelix(new MockClient([ + CreateSubscription::class => MockResponse::make([ + 'data' => [[ + 'id' => 'sub-abc', + 'type' => 'stream.online', + 'status' => 'enabled', + 'condition' => ['broadcaster_user_id' => '999888'], + 'transport' => ['method' => 'webhook', 'callback' => 'https://example.com/api/webhooks/twitch/eventsub'], + 'cost' => 0, + 'version' => '1', + ]], + ], 202), + ])); + + $result = resolve(RegisterTwitchSubscriptionsAction::class)('999888', [TwitchEventSubType::StreamOnline]); + + expect($result['created'])->toBe(1) + ->and($result['failed'])->toBe(0); + + assertDatabaseHas(TwitchSubscription::class, [ + 'subscription_id' => 'sub-abc', + 'broadcaster_user_id' => '999888', + 'type' => 'stream.online', + ]); +}); + +test('pula subscriptions já existentes para o mesmo broadcaster', function (): void { + TwitchSubscription::query()->create([ + 'subscription_id' => 'existing-1', + 'type' => 'stream.online', + 'status' => 'enabled', + 'broadcaster_user_id' => '999888', + 'condition' => ['broadcaster_user_id' => '999888'], + 'transport' => 'webhook', + 'callback_url' => 'https://example.com/api/webhooks/twitch/eventsub', + 'cost' => 0, + 'version' => '1', + ]); + + bindMockedHelix(new MockClient([ + CreateSubscription::class => MockResponse::make(['data' => [['id' => 'should-not-be-used']]], 202), + ])); + + $result = resolve(RegisterTwitchSubscriptionsAction::class)('999888', [TwitchEventSubType::StreamOnline]); + + expect($result['created'])->toBe(0) + ->and($result['skipped'])->toBe(1); +}); diff --git a/app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php b/app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php index ed4f28533..fd17890c7 100644 --- a/app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php +++ b/app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php @@ -3,10 +3,13 @@ declare(strict_types=1); use He4rt\IntegrationTwitch\Enums\TwitchEventSubType; +use He4rt\IntegrationTwitch\OAuth\TwitchAppTokenService; use He4rt\IntegrationTwitch\Transport\Requests\EventSub\CreateSubscription; use He4rt\IntegrationTwitch\Transport\Requests\EventSub\DeleteSubscription; use He4rt\IntegrationTwitch\Transport\Requests\EventSub\ListSubscriptions; use He4rt\IntegrationTwitch\Transport\TwitchHelixConnector; +use He4rt\IntegrationTwitch\Transport\TwitchOAuthConnector; +use Illuminate\Support\Facades\Cache; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; @@ -24,8 +27,17 @@ function mockEventSubResponses(array $existingSubscriptions = []): MockClient DeleteSubscription::class => MockResponse::make([], 204), ]); + // The app token is resolved lazily by the connector; seed the cache so + // getToken() short-circuits without hitting the real Twitch OAuth endpoint. + Cache::put('twitch_app_access_token', 'fake-token', 3_600); + app()->instance(TwitchHelixConnector::class, tap( - new TwitchHelixConnector(appToken: 'fake-token', clientId: 'fake-client-id'), + new TwitchHelixConnector( + tokenService: new TwitchAppTokenService( + new TwitchOAuthConnector(clientId: 'fake-client-id', clientSecret: 'fake-secret'), + ), + clientId: 'fake-client-id', + ), fn (TwitchHelixConnector $connector) => $connector->withMockClient($mock), )); diff --git a/app-modules/integration-twitch/tests/Feature/TwitchAppTokenServiceTest.php b/app-modules/integration-twitch/tests/Feature/TwitchAppTokenServiceTest.php new file mode 100644 index 000000000..d78f20b68 --- /dev/null +++ b/app-modules/integration-twitch/tests/Feature/TwitchAppTokenServiceTest.php @@ -0,0 +1,50 @@ +withMockClient($mock); + + return new TwitchAppTokenService($connector); +} + +beforeEach(function (): void { + Cache::flush(); +}); + +test('retorna e cacheia o app token quando a twitch responde com sucesso', function (): void { + $service = makeAppTokenService(new MockClient([ + GetAppAccessToken::class => MockResponse::make([ + 'access_token' => 'app-token-123', + 'expires_in' => 5_000, + 'token_type' => 'bearer', + ]), + ])); + + expect($service->getToken())->toBe('app-token-123'); + expect(Cache::get('twitch_app_access_token'))->toBe('app-token-123'); +}); + +test('lança OAuthFlowException quando a twitch nega o client secret', function (): void { + $service = makeAppTokenService(new MockClient([ + GetAppAccessToken::class => MockResponse::make([ + 'status' => 403, + 'message' => 'invalid client secret', + ], 403), + ])); + + expect(fn (): string => $service->getToken()) + ->toThrow(OAuthFlowException::class, 'invalid client secret'); + + expect(Cache::has('twitch_app_access_token'))->toBeFalse(); +}); diff --git a/app-modules/integration-twitch/tests/Feature/TwitchWebhookTest.php b/app-modules/integration-twitch/tests/Feature/TwitchWebhookTest.php index 6605e4994..ae14fab90 100644 --- a/app-modules/integration-twitch/tests/Feature/TwitchWebhookTest.php +++ b/app-modules/integration-twitch/tests/Feature/TwitchWebhookTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationTwitch\Models\TwitchEventLog; use Illuminate\Support\Str; use Illuminate\Testing\TestResponse; @@ -61,7 +60,7 @@ function postTwitchWebhook(array $payload, string $messageType = 'notification', $headers = signedTwitchHeaders($body, $messageType, $messageId); return test()->postJson( - '/api/webhooks/twitch/eventsub/'.test()->tenant->slug, + '/api/webhooks/twitch/eventsub', $payload, $headers, ); @@ -69,11 +68,10 @@ function postTwitchWebhook(array $payload, string $messageType = 'notification', beforeEach(function (): void { config()->set('services.twitch.eventsub_secret', 'test-secret-at-least-ten-chars'); - test()->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); }); test('rejects request with missing twitch headers', function (): void { - $this->postJson('/api/webhooks/twitch/eventsub/'.$this->tenant->slug, ['foo' => 'bar']) + $this->postJson('/api/webhooks/twitch/eventsub', ['foo' => 'bar']) ->assertStatus(403); }); @@ -84,7 +82,7 @@ function postTwitchWebhook(array $payload, string $messageType = 'notification', $headers = signedTwitchHeaders($body); $headers['Twitch-Eventsub-Message-Signature'] = 'sha256=invalid'; - $this->postJson('/api/webhooks/twitch/eventsub/'.$this->tenant->slug, $payload, $headers) + $this->postJson('/api/webhooks/twitch/eventsub', $payload, $headers) ->assertStatus(403); }); @@ -95,7 +93,7 @@ function postTwitchWebhook(array $payload, string $messageType = 'notification', $headers = signedTwitchHeaders($body, 'notification', timestamp: $timestamp); - $this->postJson('/api/webhooks/twitch/eventsub/'.$this->tenant->slug, $payload, $headers) + $this->postJson('/api/webhooks/twitch/eventsub', $payload, $headers) ->assertStatus(403); }); @@ -117,7 +115,6 @@ function postTwitchWebhook(array $payload, string $messageType = 'notification', ->and($log->event_type)->toBe('channel.follow') ->and($log->broadcaster_user_id)->toBe('12345') ->and($log->user_id)->toBe('67890') - ->and($log->tenant_id)->toBe($this->tenant->id) ->and($log->twitch_message_id)->not->toBeNull() ->and($log->payload)->toBeArray(); }); diff --git a/app-modules/moderation/database/factories/ModerationCaseFactory.php b/app-modules/moderation/database/factories/ModerationCaseFactory.php index 2856808a6..f0b6b3446 100644 --- a/app-modules/moderation/database/factories/ModerationCaseFactory.php +++ b/app-modules/moderation/database/factories/ModerationCaseFactory.php @@ -4,7 +4,6 @@ namespace He4rt\Moderation\Database\Factories; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Cases\Models\ModerationCase; use Illuminate\Database\Eloquent\Factories\Factory; @@ -28,7 +27,6 @@ public function definition(): array 'violation_type' => fake()->randomElement(['spam', 'toxicity', 'harassment']), 'ai_scores' => ['spam' => fake()->randomFloat(2, 0, 1)], 'author_id' => User::factory(), - 'tenant_id' => Tenant::factory(), ]; } diff --git a/app-modules/moderation/database/migrations/2026_07_15_001259_recreate_moderation_post_tenancy_indexes.php b/app-modules/moderation/database/migrations/2026_07_15_001259_recreate_moderation_post_tenancy_indexes.php new file mode 100644 index 000000000..1a76a8d89 --- /dev/null +++ b/app-modules/moderation/database/migrations/2026_07_15_001259_recreate_moderation_post_tenancy_indexes.php @@ -0,0 +1,24 @@ + */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return BelongsTo */ public function action(): BelongsTo { diff --git a/app-modules/moderation/src/Audit/ModerationAuditLog.php b/app-modules/moderation/src/Audit/ModerationAuditLog.php index 17e76e99e..1ec4b3404 100644 --- a/app-modules/moderation/src/Audit/ModerationAuditLog.php +++ b/app-modules/moderation/src/Audit/ModerationAuditLog.php @@ -16,7 +16,6 @@ * @property string|null $case_id * @property array $details * @property string|null $platform - * @property string|null $tenant_id * @property CarbonInterface $created_at */ #[Table(name: 'moderation_audit_log', timestamps: false)] diff --git a/app-modules/moderation/src/Audit/RecordAuditLog.php b/app-modules/moderation/src/Audit/RecordAuditLog.php index 561e09c03..658d13b97 100644 --- a/app-modules/moderation/src/Audit/RecordAuditLog.php +++ b/app-modules/moderation/src/Audit/RecordAuditLog.php @@ -23,7 +23,6 @@ public function handleCaseCreated(CaseCreated $event): void 'content_type' => $event->case->content_type, ], 'platform' => $event->case->source_platform->value, - 'tenant_id' => $event->case->tenant_id, ]); } @@ -39,7 +38,6 @@ public function handleCaseResolved(CaseResolved $event): void 'violation_type' => $event->case->violation_type?->value, ], 'platform' => $event->case->source_platform->value, - 'tenant_id' => $event->case->tenant_id, ]); } @@ -57,7 +55,6 @@ public function handleActionExecuted(ActionExecuted $event): void 'execution_results' => $event->action->execution_results, ], 'platform' => null, - 'tenant_id' => $event->action->case?->tenant_id, ]); } } diff --git a/app-modules/moderation/src/Cases/Models/ModerationCase.php b/app-modules/moderation/src/Cases/Models/ModerationCase.php index f2ab6c864..536f1b763 100644 --- a/app-modules/moderation/src/Cases/Models/ModerationCase.php +++ b/app-modules/moderation/src/Cases/Models/ModerationCase.php @@ -5,7 +5,6 @@ namespace He4rt\Moderation\Cases\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Database\Factories\ModerationCaseFactory; use He4rt\Moderation\Enforcement\ModerationAction; @@ -39,7 +38,6 @@ * @property ActionType|null $suggested_action * @property string|null $assigned_to * @property string|null $author_id - * @property string|null $tenant_id * @property CarbonInterface|null $assigned_at * @property CarbonInterface|null $resolved_at * @property CarbonInterface $created_at @@ -65,12 +63,6 @@ public function assignee(): BelongsTo return $this->belongsTo(User::class, 'assigned_to'); } - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return HasMany */ public function reports(): HasMany { diff --git a/app-modules/moderation/src/Classification/Actions/Classifiers/RuleBasedClassifier.php b/app-modules/moderation/src/Classification/Actions/Classifiers/RuleBasedClassifier.php index de182488d..2a42f5496 100644 --- a/app-modules/moderation/src/Classification/Actions/Classifiers/RuleBasedClassifier.php +++ b/app-modules/moderation/src/Classification/Actions/Classifiers/RuleBasedClassifier.php @@ -8,7 +8,6 @@ use He4rt\Moderation\DTOs\ClassificationResultDTO; use He4rt\Moderation\DTOs\ModerationContentDTO; use He4rt\Moderation\Rules\ModerationRule; -use Illuminate\Contracts\Database\Query\Builder; final class RuleBasedClassifier implements ContentClassifierContract { @@ -21,9 +20,6 @@ public function classify(ModerationContentDTO $content): ClassificationResultDTO { $rules = ModerationRule::query() ->where('is_active', operator: true) - ->when($content->tenantId, fn ($q) => $q->where(static function (Builder $q) use ($content): void { - $q->where('tenant_id', $content->tenantId)->orWhereNull('tenant_id'); - })) ->get(); $scores = []; diff --git a/app-modules/moderation/src/Classification/Jobs/IngestContent.php b/app-modules/moderation/src/Classification/Jobs/IngestContent.php index 28ac2cea9..2acf84c24 100644 --- a/app-modules/moderation/src/Classification/Jobs/IngestContent.php +++ b/app-modules/moderation/src/Classification/Jobs/IngestContent.php @@ -35,7 +35,6 @@ public function handle(): ModerationCase $identity = resolve(FindExternalIdentity::class)->handle( provider: IdentityProvider::Discord->value, providerId: $this->content->authorExternalId, - tenantId: $this->content->tenantId, ); $authorId = $identity->model_id; } catch (Throwable) { @@ -52,7 +51,6 @@ public function handle(): ModerationCase 'status' => CaseStatus::Pending, 'priority' => 50, 'author_id' => $authorId, - 'tenant_id' => $this->content->tenantId, ]); event(new CaseCreated($case)); diff --git a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php index b825980d5..0031939d5 100644 --- a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php +++ b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php @@ -75,7 +75,6 @@ public function handle(RouteCaseAction $routeAction): void 'status' => CaseStatus::Pending, 'priority' => 50, 'author_id' => $authorId, - 'tenant_id' => $this->content->tenantId, 'ai_scores' => $result->scores, 'violation_type' => $result->primary, 'severity' => $result->severity, diff --git a/app-modules/moderation/src/DTOs/ModerationContentDTO.php b/app-modules/moderation/src/DTOs/ModerationContentDTO.php index 95c7cc657..2227ba6dc 100644 --- a/app-modules/moderation/src/DTOs/ModerationContentDTO.php +++ b/app-modules/moderation/src/DTOs/ModerationContentDTO.php @@ -24,7 +24,6 @@ public function __construct( public array $mediaUrls, public array $metadata, public array $snapshot, - public ?string $tenantId, ) {} public static function fromCase(ModerationCase $case): self @@ -38,7 +37,6 @@ public static function fromCase(ModerationCase $case): self mediaUrls: $case->content_snapshot['media_urls'] ?? [], metadata: $case->content_snapshot['metadata'] ?? [], snapshot: $case->content_snapshot ?? [], - tenantId: $case->tenant_id !== null ? (string) $case->tenant_id : null, ); } @@ -56,7 +54,6 @@ public static function fromPlatform(Platform $platform, array $rawPayload): self mediaUrls: $rawPayload['media_urls'] ?? [], metadata: $rawPayload['metadata'] ?? [], snapshot: $rawPayload, - tenantId: $rawPayload['tenant_id'] ?? null, ); } @@ -74,7 +71,6 @@ public function jsonSerialize(): array 'media_urls' => $this->mediaUrls, 'metadata' => $this->metadata, 'snapshot' => $this->snapshot, - 'tenant_id' => $this->tenantId, ]; } } diff --git a/app-modules/moderation/src/Enforcement/ModerationAction.php b/app-modules/moderation/src/Enforcement/ModerationAction.php index 7ba8baba9..ca30511c0 100644 --- a/app-modules/moderation/src/Enforcement/ModerationAction.php +++ b/app-modules/moderation/src/Enforcement/ModerationAction.php @@ -5,7 +5,6 @@ namespace He4rt\Moderation\Enforcement; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Appeals\ModerationAppeal; use He4rt\Moderation\Cases\Models\ModerationCase; @@ -30,7 +29,6 @@ * @property array|null $metadata * @property array|null $execution_results * @property bool $automated - * @property string|null $tenant_id * @property CarbonInterface $created_at */ #[Table(name: 'moderation_actions', timestamps: false)] @@ -53,12 +51,6 @@ public function moderator(): BelongsTo return $this->belongsTo(User::class, 'moderator_id'); } - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return HasOne */ public function appeal(): HasOne { diff --git a/app-modules/moderation/src/Pipeline/SubmitForModeration.php b/app-modules/moderation/src/Pipeline/SubmitForModeration.php index 1c3389cde..8d2a203b5 100644 --- a/app-modules/moderation/src/Pipeline/SubmitForModeration.php +++ b/app-modules/moderation/src/Pipeline/SubmitForModeration.php @@ -58,7 +58,6 @@ public function execute(ModerationContentDTO $content, CaseSource $source): ?Mod 'status' => CaseStatus::Pending, 'priority' => 50, 'author_id' => $this->resolveAuthorId($content), - 'tenant_id' => $content->tenantId, 'ai_scores' => $ruleResult->scores, 'violation_type' => $ruleResult->primary, 'severity' => $ruleResult->severity, diff --git a/app-modules/moderation/src/Rules/ModerationRule.php b/app-modules/moderation/src/Rules/ModerationRule.php index e6d3f116e..c1803fce0 100644 --- a/app-modules/moderation/src/Rules/ModerationRule.php +++ b/app-modules/moderation/src/Rules/ModerationRule.php @@ -5,7 +5,6 @@ namespace He4rt\Moderation\Rules; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Moderation\Enums\ActionType; use He4rt\Moderation\Enums\Platform; use He4rt\Moderation\Enums\Severity; @@ -13,7 +12,6 @@ use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property string $id @@ -25,7 +23,6 @@ * @property Severity $severity * @property ActionType $action_on_match * @property bool $is_active - * @property string|null $tenant_id * @property CarbonInterface $created_at * @property CarbonInterface $updated_at */ @@ -43,12 +40,6 @@ public function matches(string $text): bool }; } - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** @return array */ protected function casts(): array { diff --git a/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php b/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php index ede8dbf91..362172c65 100644 --- a/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php +++ b/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Audit\ModerationAuditLog; use He4rt\Moderation\Audit\RecordAuditLog; @@ -85,14 +84,3 @@ expect($log->actor_type)->toBe('system') ->and($log->actor_id)->toBeNull(); }); - -test('audit log stores tenant_id from case', function (): void { - $tenant = Tenant::factory()->create(); - $case = ModerationCase::factory()->create(['tenant_id' => $tenant->id]); - - $listener = new RecordAuditLog(); - $listener->handleCaseCreated(new CaseCreated($case)); - - $log = ModerationAuditLog::query()->where('event_type', 'case_created')->first(); - expect($log->tenant_id)->toBe($tenant->id); -}); diff --git a/app-modules/moderation/tests/Feature/CaseWorkflowTest.php b/app-modules/moderation/tests/Feature/CaseWorkflowTest.php index 4698f36cc..38c56a5f3 100644 --- a/app-modules/moderation/tests/Feature/CaseWorkflowTest.php +++ b/app-modules/moderation/tests/Feature/CaseWorkflowTest.php @@ -32,7 +32,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'some bad content'], - tenantId: null, ), reason: ViolationType::Toxicity, details: 'This user is being toxic', @@ -70,7 +69,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'bad stuff'], - tenantId: null, ); $case1 = $action->handle($reporter1, $dto, ViolationType::Spam, details: null, platform: Platform::Discord); diff --git a/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php b/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php index a348dd260..19e0c00c2 100644 --- a/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php +++ b/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php @@ -31,7 +31,6 @@ function makeReportDTO(string $contentId = 'msg-1', string $text = 'test'): Mode mediaUrls: [], metadata: [], snapshot: ['text' => $text], - tenantId: null, ); } diff --git a/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php b/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php index 517192729..61afa0248 100644 --- a/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php +++ b/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Moderation\Classification\Actions\Classifiers\RuleBasedClassifier; use He4rt\Moderation\DTOs\ModerationContentDTO; use He4rt\Moderation\Enums\Platform; @@ -10,7 +9,7 @@ use He4rt\Moderation\Enums\ViolationType; use He4rt\Moderation\Rules\ModerationRule; -function contentDTO(string $text, ?string $tenantId = null): ModerationContentDTO +function contentDTO(string $text): ModerationContentDTO { return new ModerationContentDTO( contentId: 'msg-test', @@ -21,7 +20,6 @@ function contentDTO(string $text, ?string $tenantId = null): ModerationContentDT mediaUrls: [], metadata: [], snapshot: ['text' => $text], - tenantId: $tenantId, ); } @@ -122,37 +120,6 @@ function contentDTO(string $text, ?string $tenantId = null): ModerationContentDT ->and($result->matchedRules)->toHaveCount(2); }); -test('tenant-scoped rules only match for correct tenant', function (): void { - $tenant = Tenant::factory()->create(); - $otherTenant = Tenant::factory()->create(); - - ModerationRule::query()->create([ - 'name' => 'Tenant rule', 'type' => 'keyword', 'pattern' => 'specific', - 'violation_type' => 'spam', 'severity' => 'medium', 'action_on_match' => 'warn', - 'is_active' => true, 'tenant_id' => $tenant->id, - ]); - - $matchResult = RuleBasedClassifier::make()->classify(contentDTO('something specific', (string) $tenant->id)); - $noMatchResult = RuleBasedClassifier::make()->classify(contentDTO('something specific', (string) $otherTenant->id)); - - expect($matchResult->scores)->toHaveKey('spam') - ->and($noMatchResult->scores)->toBeEmpty(); -}); - -test('global rules (null tenant) match for any tenant', function (): void { - $anyTenant = Tenant::factory()->create(); - - ModerationRule::query()->create([ - 'name' => 'Global', 'type' => 'keyword', 'pattern' => 'universal', - 'violation_type' => 'harassment', 'severity' => 'high', 'action_on_match' => 'mute', - 'is_active' => true, 'tenant_id' => null, - ]); - - $result = RuleBasedClassifier::make()->classify(contentDTO('universal truth', (string) $anyTenant->id)); - - expect($result->primary)->toBe(ViolationType::Harassment); -}); - test('returns empty when no rules exist', function (): void { $result = RuleBasedClassifier::make()->classify(contentDTO('anything at all')); diff --git a/app-modules/moderation/tests/Feature/ModelRelationshipTest.php b/app-modules/moderation/tests/Feature/ModelRelationshipTest.php index 21ab92718..f5c7e6f3b 100644 --- a/app-modules/moderation/tests/Feature/ModelRelationshipTest.php +++ b/app-modules/moderation/tests/Feature/ModelRelationshipTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Appeals\ModerationAppeal; use He4rt\Moderation\Cases\Models\ModerationCase; @@ -44,13 +43,6 @@ expect($case->assignee->id)->toBe($moderator->id); }); -test('ModerationCase belongs to tenant', function (): void { - $tenant = Tenant::factory()->create(); - $case = ModerationCase::factory()->create(['tenant_id' => $tenant->id]); - - expect($case->tenant->id)->toBe($tenant->id); -}); - test('ModerationAction belongs to case', function (): void { $case = ModerationCase::factory()->create(); $action = ModerationAction::factory()->create(['case_id' => $case->id]); diff --git a/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php b/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php index 8fb58c459..0b8e2e9ff 100644 --- a/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php +++ b/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php @@ -50,7 +50,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'extremely toxic content here'], - tenantId: null, ); $job = new ScreenContent($dto, CaseSource::AutoDetect); @@ -92,7 +91,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'this is a normal message'], - tenantId: null, ); $job = new ScreenContent($dto, CaseSource::AutoDetect); @@ -128,7 +126,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'very harassing content'], - tenantId: null, ); $job = new ScreenContent($dto, CaseSource::AutoDetect); diff --git a/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php b/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php index 95c636d64..25c43e51d 100644 --- a/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php +++ b/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php @@ -49,7 +49,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'buy followers now at spam.com'], - tenantId: null, ); $action = resolve(SubmitForModeration::class); @@ -80,7 +79,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'hello everyone, how are you?'], - tenantId: null, ); $action = resolve(SubmitForModeration::class); @@ -105,7 +103,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => ''], - tenantId: null, ); $action = resolve(SubmitForModeration::class); diff --git a/app-modules/moderation/tests/Feature/PipelineTest.php b/app-modules/moderation/tests/Feature/PipelineTest.php index f4aaade5a..8c9dd3290 100644 --- a/app-modules/moderation/tests/Feature/PipelineTest.php +++ b/app-modules/moderation/tests/Feature/PipelineTest.php @@ -24,7 +24,6 @@ mediaUrls: [], metadata: ['channel_id' => 'ch-1'], snapshot: ['text' => 'some spam content'], - tenantId: null, ); $job = new IngestContent($dto, CaseSource::UserReport); @@ -129,7 +128,6 @@ mediaUrls: [], metadata: [], snapshot: ['text' => 'Get free followers at spam.com'], - tenantId: null, ); $case = new IngestContent($dto, CaseSource::AutoDetect)->handle(); diff --git a/app-modules/moderation/tests/Feature/Platform/WebAdapterTest.php b/app-modules/moderation/tests/Feature/Platform/WebAdapterTest.php index e59e70632..279201ce4 100644 --- a/app-modules/moderation/tests/Feature/Platform/WebAdapterTest.php +++ b/app-modules/moderation/tests/Feature/Platform/WebAdapterTest.php @@ -183,7 +183,6 @@ 'text' => 'Some post content', 'media_urls' => ['https://example.com/img.jpg'], 'metadata' => ['url' => '/posts/123'], - 'tenant_id' => '1', 'author_external_id' => 'user-uuid', ]; @@ -194,8 +193,7 @@ ->and($dto->contentType)->toBe('post') ->and($dto->sourcePlatform)->toBe(Platform::Web) ->and($dto->textContent)->toBe('Some post content') - ->and($dto->mediaUrls)->toBe(['https://example.com/img.jpg']) - ->and($dto->tenantId)->toBe('1'); + ->and($dto->mediaUrls)->toBe(['https://example.com/img.jpg']); }); test('resolveUser finds user by ID', function (): void { diff --git a/app-modules/moderation/tests/Unit/ClassifierTest.php b/app-modules/moderation/tests/Unit/ClassifierTest.php index d82d608e3..26a295636 100644 --- a/app-modules/moderation/tests/Unit/ClassifierTest.php +++ b/app-modules/moderation/tests/Unit/ClassifierTest.php @@ -28,7 +28,6 @@ function makeContentDTO(string $text = 'hello world'): ModerationContentDTO mediaUrls: [], metadata: [], snapshot: ['text' => $text], - tenantId: null, ); } diff --git a/app-modules/moderation/tests/Unit/DTOTest.php b/app-modules/moderation/tests/Unit/DTOTest.php index a1dca4437..decd516ab 100644 --- a/app-modules/moderation/tests/Unit/DTOTest.php +++ b/app-modules/moderation/tests/Unit/DTOTest.php @@ -21,7 +21,6 @@ mediaUrls: ['https://example.com/img.png'], metadata: ['channel_id' => '123', 'guild_id' => '456'], snapshot: ['raw' => 'full message data'], - tenantId: 'tenant-uuid', ); expect($dto->contentId)->toBe('msg-123') @@ -31,8 +30,7 @@ ->and($dto->textContent)->toBe('spam content here') ->and($dto->mediaUrls)->toBe(['https://example.com/img.png']) ->and($dto->metadata)->toBe(['channel_id' => '123', 'guild_id' => '456']) - ->and($dto->snapshot)->toBe(['raw' => 'full message data']) - ->and($dto->tenantId)->toBe('tenant-uuid'); + ->and($dto->snapshot)->toBe(['raw' => 'full message data']); }); test('ClassificationResultDTO holds scores and primary violation', function (): void { @@ -103,7 +101,6 @@ classifierName: 'openai', mediaUrls: ['https://example.com/file.png'], metadata: ['channel_id' => 'ch-1'], snapshot: ['text' => 'test serialization'], - tenantId: 'tenant-1', ); $json = json_encode($dto); @@ -115,7 +112,6 @@ classifierName: 'openai', ->and($decoded['source_platform'])->toBe('discord') ->and($decoded['author_external_id'])->toBe('ext-123') ->and($decoded['text_content'])->toBe('test serialization') - ->and($decoded['tenant_id'])->toBe('tenant-1') ->and($decoded)->not->toHaveKey('author'); }); @@ -129,7 +125,6 @@ classifierName: 'openai', mediaUrls: [], metadata: [], snapshot: ['text' => 'content without author'], - tenantId: null, ); $serialized = $dto->jsonSerialize(); @@ -147,12 +142,10 @@ classifierName: 'openai', 'text' => 'platform content', 'media_urls' => [], 'metadata' => ['guild_id' => 'g-1'], - 'tenant_id' => 'tenant-2', ]); expect($dto->contentId)->toBe('msg-fp-1') ->and($dto->sourcePlatform)->toBe(Platform::Discord) ->and($dto->authorExternalId)->toBe('ext-789') - ->and($dto->textContent)->toBe('platform content') - ->and($dto->tenantId)->toBe('tenant-2'); + ->and($dto->textContent)->toBe('platform content'); }); diff --git a/app-modules/panel-admin/config/panel-admin.php b/app-modules/panel-admin/config/panel-admin.php index 8c8b65233..0e472e872 100644 --- a/app-modules/panel-admin/config/panel-admin.php +++ b/app-modules/panel-admin/config/panel-admin.php @@ -2,23 +2,9 @@ declare(strict_types=1); -use He4rt\Moderation\Appeals\ModerationAppeal; -use He4rt\Moderation\Audit\ModerationAuditLog; -use He4rt\Moderation\Cases\Models\ModerationCase; -use He4rt\Moderation\Enforcement\ModerationAction; -use He4rt\Moderation\Rules\ModerationRule; - return [ 'modules' => [ // Modules whose Filament/Admin/ resources will be discovered // Ex: 'identity', 'gamification', 'events' ], - - 'tenant_scoped_models' => [ - ModerationCase::class, - ModerationAction::class, - ModerationAppeal::class, - ModerationRule::class, - ModerationAuditLog::class, - ], ]; diff --git a/app-modules/panel-admin/docs/plans/2026-07-12-refinamento-cluster-discord.md b/app-modules/panel-admin/docs/plans/2026-07-12-refinamento-cluster-discord.md new file mode 100644 index 000000000..6a3af920e --- /dev/null +++ b/app-modules/panel-admin/docs/plans/2026-07-12-refinamento-cluster-discord.md @@ -0,0 +1,1175 @@ +--- +type: plan +title: "Refinamento do cluster Discord no painel admin" +module: panel-admin +status: proposed +date: 2026-07-12 +author: danielhe4rt +--- + +# Refinamento do cluster Discord (panel-admin) + +> **Para o agente implementador**: este plano é autossuficiente. Todos os +> namespaces, URLs de docs, configs e convenções do projeto necessários estão +> copiados aqui. Não é preciso consultar os arquivos do skill de planejamento. + +## Motivação + +O cluster Discord (`app-modules/panel-admin/src/Discord/`) foi scaffoldado via +CLI e está no estado cru: todas as colunas como `TextColumn` sem formatação, +zero filtros, labels hardcoded em inglês tipo "Discord Guild Id", CRUD completo +habilitado (Create/Edit/Delete) e infolists configuradas mas **sem View page +registrada** (ou seja, nunca renderizam). Os dados são espelho do Discord +sincronizado pelo bot/ETL — em produção: 1 guild (He4rt Developers), 272 +canais, 26.256 membros, 82 roles e 237.862 event logs (48 tipos de evento). + +## Decisões tomadas com o usuário (3 rodadas de perguntas) + +| Decisão | Escolha | +| --- | --- | +| CRUD | **Somente leitura** — List + View em todos os 5 resources; remover Create/Edit/Delete | +| Dashboard | **Sim** — página com Stats overview, Eventos por dia (chart) e Crescimento de membros (chart) | +| Sync action no painel | **Não** — sync continua só via artisan | +| Drill-down | Relation managers na Guild (Channels/Roles/Members) + links cruzados nas tabelas | +| Event logs / índice | **Migration** adicionando índice em `created_at` | +| Members default | **Ativos por padrão** (esconde quem tem `left_at`), com deferred loading | +| Channels | **Agrupado por categoria** (parent), position dentro do grupo, tipo como badge | +| Retenção de event logs | 100% read-only (prune fica fora do escopo) | +| i18n | Lang files `panel-admin::discord.*` em **en + pt_BR** (padrão do cluster Twitch) | +| Role history | **Fora do escopo** | +| Testes | **Smoke tests** (cada página renderiza com dados de factory) | + +## Convenções do projeto que este plano obedece + +- Padrão-ouro a copiar: `app-modules/panel-admin/src/Twitch/` (TwitchEventLogResource, + TwitchDashboard, TwitchStatsWidget, lang/en|pt_BR/twitch.php). +- Datas exibidas ao usuário: `->dateTime('d/m/Y H:i')->timezone(config('app.display_timezone'))`. +- Enums novos/estendidos implementam os contratos Filament (`HasLabel`, `HasColor`, + `HasDescription`, `HasIcon` quando fizer sentido) com `match ($this)` cobrindo + **todos** os cases, sem `default` (precedente: enums do módulo moderation). +- Migrations sempre via `php artisan make:migration --module=integration-discord`. +- Após alterar PHP: `vendor/bin/pint --dirty --format agent` e `vendor/bin/phpstan analyse`. +- Testes: Pest, `php artisan test --compact --filter=...`. + +## Mapa de navegação (estado final) + +``` +Admin Panel (sidebar) +└── 🗨️ Discord (DiscordCluster, sort 40, ícone OutlinedChatBubbleLeftRight) + │ + ├── Visão geral (nav group "group_overview") + │ └── 📊 Dashboard (DiscordDashboard page, sort 0) + │ ├── DiscordStatsWidget (header, full width) + │ ├── EventsPerDayChartWidget (footer, col 1) + │ └── MemberGrowthChartWidget (footer, col 2) + │ + ├── Servidor (nav group "group_server") + │ ├── 🖥️ Guilds (sort 1) List → View (+ RMs: Channels, Roles, Members) + │ ├── # Channels (sort 2) List (agrupada por categoria) → View + │ ├── 🏷️ Roles (sort 3) List → View + │ └── 👥 Members (sort 4) List (ativos por padrão) → View + │ + └── Eventos (nav group "group_events") + └── 📜 Event Logs (sort 1) List → View (payload JSON) +``` + +## Fluxo de usuário principal + +``` + USER (admin) SYSTEM (panel-admin) + │ │ + │ 👆 clica "Discord > Event Logs" │ + │ ─────────────────────────────────► │ + │ │ ListDiscordEventLogs: deferLoading=true + │ │ query: ORDER BY created_at DESC (índice novo) + │ │ render: badges por categoria de evento ✓ + │ │ + │ 📱 tabela com 25 linhas, │ + │ badges coloridas por tipo │ + │ ◄───────────────────────────────── │ + │ │ + │ ┌──────────────────────────┐ │ + │ │ Filtro: Tipo de evento ▾ │ │ + │ │ Filtro: Período (de/até) │ │ + │ └──────────────────────────┘ │ + │ │ + │ 👆 filtra "GUILD_BAN_ADD" │ + │ ─────────────────────────────────► │ + │ │ SelectFilter: event_type = GUILD_BAN_ADD + │ │ query usa índice event_type ✓ (106 rows) + │ │ + │ "106 registros encontrados" │ + │ ◄───────────────────────────────── │ + │ │ + │ 👆 clica numa linha (ViewAction) │ + │ ─────────────────────────────────► │ + │ │ ViewDiscordEventLog: infolist + │ │ Seção "Evento": ids copiáveis + │ │ Seção "Payload": CodeEntry JSON pretty ✓ + │ │ user_id resolvível → link p/ Member ✓ + │ │ + │ 📱 payload formatado + link │ + │ "Ver membro banido" │ + │ ◄───────────────────────────────── │ +``` + +--- + +# 1. Comandos (rodar antes de escrever código) + +```bash +# Índice para ordenação/filtragem por data nos event logs (237k linhas) +php artisan make:migration add_created_at_index_to_discord_event_logs_table --module=integration-discord --table=discord_event_logs +``` + +Nenhum outro comando de scaffold: os resources já existem; as View pages, +relation managers, widgets e a página de dashboard serão criados **manualmente** +nos caminhos do módulo (`app-modules/panel-admin/src/Discord/...`), porque os +geradores do Filament não conhecem os paths do `internachi/modular`. Os +esqueletos de referência estão em cada seção abaixo. + +# 2. Migration + Models + +## 2.1 Migration: índice em `created_at` + +**Contexto**: `discord_event_logs` tem 237k linhas e cresce ~4k/dia. Hoje só +`event_type` e `guild_id` são indexados; `defaultSort('created_at', 'desc')` e o +filtro de período fariam seq scan. Nenhuma coluna muda → **não** há PHPDoc de +model a atualizar. + +```php +// database/migrations/xxxx_add_created_at_index_to_discord_event_logs_table.php +Schema::table('discord_event_logs', function (Blueprint $table): void { + $table->index('created_at'); +}); +// down(): $table->dropIndex(['created_at']); +``` + +**Expected behavior** +- **Dado** a migration aplicada, **então** `\d discord_event_logs` mostra + `discord_event_logs_created_at_index`. +- **Dado** rollback, **então** o índice é removido sem afetar dados. + +## 2.2 Enum `DiscordChannelType` — contratos Filament + +**Contexto**: `He4rt\IntegrationDiscord\Enums\DiscordChannelType` (int-backed, +12 cases) é exibido cru como número na tabela de canais. A coluna `type` vira +badge; o enum precisa dos contratos Filament (precedente: enums de moderation). +Enum **não ordenado** → cores semânticas distintas por case (a regra do "heat +ramp" não se aplica). + +Antes / depois: + +```php +// ANTES +enum DiscordChannelType: int +{ + case GuildText = 0; + // ... +} + +// DEPOIS +use Filament\Support\Contracts\HasColor; +use Filament\Support\Contracts\HasDescription; +use Filament\Support\Contracts\HasIcon; +use Filament\Support\Contracts\HasLabel; +use Filament\Support\Icons\Heroicon; + +enum DiscordChannelType: int implements HasColor, HasDescription, HasIcon, HasLabel +{ + case GuildText = 0; + // ... cases inalterados ... + + public function getLabel(): string { /* match all cases */ } + public function getColor(): string { /* match all cases */ } + public function getDescription(): ?string { /* match all cases */ } + public function getIcon(): Heroicon { /* match all cases */ } +} +``` + +Mapeamento completo (todos os getters são `match ($this)` sem `default`): + +| Case | Label | Color | Icon (`Filament\Support\Icons\Heroicon`) | +| --- | --- | --- | --- | +| GuildText | `Texto` | `gray` | `Heroicon::OutlinedHashtag` | +| Dm | `DM` | `info` | `Heroicon::OutlinedEnvelope` | +| GuildVoice | `Voz` | `success` | `Heroicon::OutlinedSpeakerWave` | +| GroupDm | `DM em grupo` | `info` | `Heroicon::OutlinedEnvelope` | +| GuildCategory | `Categoria` | `gray` | `Heroicon::OutlinedFolder` | +| GuildAnnouncement | `Anúncios` | `warning` | `Heroicon::OutlinedMegaphone` | +| AnnouncementThread | `Thread de anúncio` | `warning` | `Heroicon::OutlinedChatBubbleBottomCenterText` | +| PublicThread | `Thread pública` | `primary` | `Heroicon::OutlinedChatBubbleBottomCenterText` | +| PrivateThread | `Thread privada` | `primary` | `Heroicon::OutlinedChatBubbleBottomCenterText` | +| GuildStageVoice | `Palco` | `danger` | `Heroicon::OutlinedMicrophone` | +| GuildForum | `Fórum` | `primary` | `Heroicon::OutlinedChatBubbleLeftRight` | +| GuildMedia | `Mídia` | `info` | `Heroicon::OutlinedPhoto` | + +Descrições curtas (`getDescription()`): uma frase por case explicando o tipo +(ex.: GuildText → `'Canal de texto padrão do servidor'`). Labels do enum ficam +hardcoded no enum (módulo de domínio não usa lang do panel-admin). + +**Expected behavior** +- **Dado** um canal tipo 0, **então** a tabela mostra badge cinza "Texto" com + ícone hashtag (sem config manual na coluna — os contratos resolvem). +- **Dado** qualquer case novo adicionado no futuro, **então** o `match` sem + `default` força erro de compilação até preencher os 4 getters. + +## 2.3 Factory para `DiscordEventLog` + +**Contexto**: `DiscordEventLog` é o único model do cluster sem factory (os +smoke tests precisam). O model hoje não usa `HasFactory`. Seguir a guideline de +atributos explícitos ao tocar o model. + +```php +// Model DiscordEventLog — adicionar: +use He4rt\IntegrationDiscord\Database\Factories\DiscordEventLogFactory; +use Illuminate\Database\Eloquent\Attributes\UseFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; + +#[UseFactory(DiscordEventLogFactory::class)] +final class DiscordEventLog extends Model +{ + /** @use HasFactory */ + use HasFactory; + // resto inalterado +} +``` + +Factory em +`app-modules/integration-discord/database/factories/DiscordEventLogFactory.php` +(namespace `He4rt\IntegrationDiscord\Database\Factories`, seguir os siblings): + +```php +public function definition(): array +{ + return [ + 'event_type' => fake()->randomElement(['MESSAGE_CREATE', 'GUILD_MEMBER_ADD', 'VOICE_STATE_UPDATE', 'GUILD_BAN_ADD']), + 'guild_id' => (string) fake()->numerify('45292621755816####'), + 'user_id' => (string) fake()->numerify('26749964733605####'), + 'channel_id' => (string) fake()->numerify('55922413525165####'), + 'payload' => ['id' => fake()->uuid(), 'content' => fake()->sentence()], + ]; +} +``` + +# 3. i18n — lang files + +**Contexto**: nenhuma label do cluster está traduzida. Criar +`app-modules/panel-admin/lang/en/discord.php` e `lang/pt_BR/discord.php` +espelhando a estrutura de `twitch.php`. Toda label citada nas seções abaixo com +chave `panel-admin::discord.*` deve existir nos dois arquivos. + +Estrutura mínima de chaves: + +```php +return [ + 'navigation' => [ + 'cluster' => 'Discord', + 'cluster_breadcrumb' => 'Discord', + 'group_overview' => 'Overview', // pt_BR: 'Visão geral' + 'group_server' => 'Server', // pt_BR: 'Servidor' + 'group_events' => 'Events', // pt_BR: 'Eventos' + 'dashboard' => 'Dashboard', + 'guilds' => 'Guilds', + 'channels' => 'Channels', // pt_BR: 'Canais' + 'roles' => 'Roles', // pt_BR: 'Cargos' + 'members' => 'Members', // pt_BR: 'Membros' + 'event_logs' => 'Event Logs', // pt_BR: 'Logs de eventos' + ], + 'guilds' => ['label' => ..., 'plural' => ..., 'fields' => [/* uma chave por coluna/entry */], 'sections' => [...]], + 'channels' => ['label' => ..., 'plural' => ..., 'fields' => [...], 'filters' => [...], 'groups' => ['uncategorized' => ...]], + 'members' => ['label' => ..., 'plural' => ..., 'fields' => [...], 'filters' => [...]], + 'roles' => ['label' => ..., 'plural' => ..., 'fields' => [...]], + 'event_logs' => ['label' => ..., 'plural' => ..., 'fields' => [...], 'filters' => [...]], + 'dashboard' => ['heading' => ..., 'stats' => [...], 'events_per_day' => ..., 'member_growth' => [...]], +]; +``` + +O implementador define os textos finais (en + pt_BR) para cada campo citado nos +resources abaixo — nomes de campo humanos, nunca "Discord Guild Id". + +# 4. Cluster + +**Contexto**: `DiscordCluster` usa `Heroicon::OutlinedCodeBracket` (genérico) e +labels hardcoded. Arquivo: +`app-modules/panel-admin/src/Discord/DiscordCluster.php`. + +Mudanças: +- `$navigationIcon` → `Heroicon::OutlinedChatBubbleLeftRight` +- `getNavigationLabel()` → `__('panel-admin::discord.navigation.cluster')` +- `getClusterBreadcrumb()` → `__('panel-admin::discord.navigation.cluster_breadcrumb')` +- Mantém `$navigationSort = 40`, `$slug = 'discord'`, `$shouldRegisterSubNavigation = false`. + +# 5. Padrão read-only (aplica-se aos 5 resources) + +**Contexto**: hoje cada resource registra `index`/`create`/`edit`, tem +`DiscordXxxForm` e a tabela expõe Edit/Delete/DeleteBulk. Os dados são espelho +do sync — qualquer edição seria sobrescrita. + +Para **cada** resource: + +1. **Deletar** `Pages/CreateDiscordXxx.php` e `Pages/EditDiscordXxx.php`. +2. **Deletar** `Schemas/DiscordXxxForm.php` e remover o método `form()` do resource. +3. **Criar** `Pages/ViewDiscordXxx.php`: + +```php + ListDiscordGuilds::route('/'), + 'view' => ViewDiscordGuild::route('/{record}'), +]; +``` + +(usar `use` statements, não FQCN inline como está no scaffold.) + +5. **Tabela** — antes/depois das actions: + +```php +// ANTES +->recordActions([EditAction::make(), DeleteAction::make()]) +->toolbarActions([BulkActionGroup::make([DeleteBulkAction::make()])]) + +// DEPOIS +->recordActions([ViewAction::make()]) +// sem toolbarActions +``` + +`ViewAction` = `Filament\Actions\ViewAction`. Clique na linha navega para a View +page automaticamente (único page-route de record registrado). + +6. Nas `ListDiscordXxx` pages, remover `CreateAction` de `getHeaderActions()` + (o scaffold gera `[CreateAction::make()]` — deixar `[]` ou remover o método). + +**Expected behavior (todos os resources)** +- **Dado** um admin na listagem, **então** não existe botão "New/Create", nem + Edit/Delete por linha, nem bulk actions. +- **Dado** acesso direto a `/admin/discord/discord-guilds/create`, **então** 404 + (rota não registrada). +- **Dado** clique numa linha, **então** navega para a View page (infolist). + +--- + +# 6. DiscordGuildResource + +**Contexto**: 1 registro em prod (He4rt Developers, 24.723 membros, tier 3, 38 +features). A tabela atual mostra `icon` como hash de texto e `features` como +JSON cru. Arquivos em +`app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/`. + +Navegação (no resource): +- Ícone: `Heroicon::OutlinedServerStack`; sort `1` +- `getNavigationLabel()` → `__('panel-admin::discord.navigation.guilds')` +- `getNavigationGroup()` → `__('panel-admin::discord.navigation.group_server')` +- `getModelLabel()` / `getPluralModelLabel()` → `panel-admin::discord.guilds.label|plural` +- `$recordTitleAttribute = 'name'`; global search: `['name']` (manter). + +## Tabela (`Tables/DiscordGuildsTable.php`) + +Antes / depois (colunas-chave): + +```php +// ANTES +TextColumn::make('icon')->label('Icon'), +TextColumn::make('features')->label('Features'), + +// DEPOIS +ImageColumn::make('icon') + ->label(__('panel-admin::discord.guilds.fields.icon')) + ->circular() + ->state(fn (DiscordGuild $record): ?string => $record->icon + ? sprintf('https://cdn.discordapp.com/icons/%s/%s.png?size=64', $record->discord_guild_id, $record->icon) + : null), +// features sai da tabela (vira badges na View) +``` + +``` +Column: icon + Component: Filament\Tables\Columns\ImageColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/image + Config: ->circular(), ->state(closure CDN acima) + +Column: name + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->sortable(), ->weight(FontWeight::Bold), ->description(fn (DiscordGuild $record): ?string => $record->description) + Imports: Filament\Support\Enums\FontWeight + +Column: member_count + Component: Filament\Tables\Columns\TextColumn + Config: ->numeric(), ->sortable() + +Column: premium_tier + Component: Filament\Tables\Columns\TextColumn + Config: ->badge(), ->formatStateUsing(fn (int $state): string => "Tier {$state}"), ->color(fn (int $state): string => match (true) { $state >= 3 => 'success', $state >= 1 => 'warning', default => 'gray' }) + +Column: channels_count + Component: Filament\Tables\Columns\TextColumn + Config: ->counts('channels'), ->numeric() + +Column: roles_count + Component: Filament\Tables\Columns\TextColumn + Config: ->counts('roles'), ->numeric() + +Column: synced_at + Component: Filament\Tables\Columns\TextColumn + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->sortable(), ->placeholder('—') + +Column: discord_guild_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->toggleable(isToggledHiddenByDefault: true) +``` + +Sem filtros (1 registro). `->defaultSort('name')`. + +## Infolist (`Schemas/DiscordGuildInfolist.php`) + +Layout: `Columns: 1` no schema raiz (`$schema->columns(1)`), seções com +`->columns(2)` internas (largura efetiva 50% — ok). + +``` +Section: guilds.sections.overview (Filament\Schemas\Components\Section, ->columns(2)) + Entry: icon + Component: Filament\Infolists\Components\ImageEntry + Docs: https://filamentphp.com/docs/5.x/infolists/image-entry + Config: ->circular(), ->state(mesma closure CDN, size=128) + Entry: name — TextEntry, ->weight(FontWeight::Bold) + Entry: discord_guild_id — TextEntry, ->copyable() + Entry: description — TextEntry, ->columnSpanFull(), ->placeholder('—') + Entry: member_count — TextEntry, ->numeric() + Entry: premium_tier — TextEntry, ->badge() (mesma config da tabela) + Entry: synced_at — TextEntry, ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->placeholder('—') + +Section: guilds.sections.features (->collapsed()) + Entry: features + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->badge(), ->columnSpanFull() // array cast → um badge por item +``` + +## Relation Managers (drill-down escolhido pelo usuário) + +Criar manualmente em +`app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/` +(base: `Filament\Resources\RelationManagers\RelationManager`; ver +`app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/RelationManagers/MessagesRelationManager.php` +como exemplo de estrutura no repo). Registrar no resource: + +```php +public static function getRelations(): array +{ + return [ + ChannelsRelationManager::class, + RolesRelationManager::class, + MembersRelationManager::class, + ]; +} +``` + +Os 3 são **read-only**: sem `form()`, sem header/record actions exceto uma +`ViewAction` com `->url()` apontando para a View page do resource +correspondente (link cruzado): + +```php +ViewAction::make()->url(fn (DiscordChannel $record): string => DiscordChannelResource::getUrl('view', ['record' => $record])) +``` + +``` +RelationManager: ChannelsRelationManager + Relationship: channels (hasMany DiscordChannel) + Title attribute: name + Can create/edit/delete: não + Table: colunas name, type (badge — enum resolve), position (->numeric()->sortable()), nsfw (IconColumn ->boolean()) + defaultSort: position asc + +RelationManager: RolesRelationManager + Relationship: roles (hasMany DiscordRole) + Title attribute: name + Table: colunas color (ColorColumn, config da seção 8), name, position (->sortable()), is_managed (IconColumn ->boolean()) + defaultSort: position desc + +RelationManager: MembersRelationManager + Relationship: members (hasMany DiscordMember) + Title attribute: username + Table: colunas avatar (ImageColumn circular, config da seção 9), username (->searchable()), joined_at (dateTime display_timezone, ->sortable()) + defaultSort: joined_at desc + Config extra: ->deferLoading() (26k registros), ->modifyQueryUsing(fn (Builder $query) => $query->whereNull('left_at')) +``` + +**Expected behavior** +- **Dado** a View da guild He4rt, **então** vejo o ícone renderizado do CDN, + badge "Tier 3" verde, 38 features como badges na seção colapsada, e 3 abas de + relation managers (Canais, Cargos, Membros). +- **Dado** guild sem icon (null), **então** `ImageColumn`/`ImageEntry` não + quebra (state null → sem imagem). +- **Dado** clique na ViewAction de um canal dentro do relation manager, + **então** navega para a View page do DiscordChannelResource. + +--- + +# 7. DiscordChannelResource + +**Contexto**: 272 canais — 240 texto, 15 categorias (type 4), 11 voz, 3 fóruns, +2 palcos, 1 anúncios. Nomes usam emoji (`🍲 | Panela`). A lista atual é plana +com type numérico. Decisão: espelhar a sidebar do Discord — **agrupar por +categoria**, ordenar por `position` dentro do grupo, e **excluir as categorias +das linhas** (elas viram títulos de grupo). + +Navegação: ícone `Heroicon::OutlinedHashtag`, sort `2`, mesmo nav group +`group_server`, labels `panel-admin::discord.channels.*`. Global search: manter +`['name', 'guild.name', 'parent.name']` e os detalhes existentes. + +## Tabela (`Tables/DiscordChannelsTable.php`) + +Config da tabela: + +```php +->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('type', '!=', DiscordChannelType::GuildCategory) + ->with('parent')) +->defaultGroup( + Group::make('parent.name') + ->label(__('panel-admin::discord.channels.fields.category')) + ->getTitleFromRecordUsing(fn (DiscordChannel $record): string => $record->parent?->name ?? __('panel-admin::discord.channels.groups.uncategorized')) + ->collapsible() +) +->groupingSettingsHidden() +->defaultSort('position') +``` + +Imports: `Filament\Tables\Grouping\Group`, +`Illuminate\Database\Eloquent\Builder`. Docs: +https://filamentphp.com/docs/5.x/tables/grouping + +``` +Column: name + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->sortable(), ->weight(FontWeight::Medium), ->description(fn (DiscordChannel $record): ?string => $record->topic ? Str::limit($record->topic, 80) : null) + Imports: Illuminate\Support\Str, Filament\Support\Enums\FontWeight + +Column: type + Component: Filament\Tables\Columns\TextColumn + Config: ->badge() // label/cor/ícone vêm do enum (seção 2.2) + +Column: position + Component: Filament\Tables\Columns\TextColumn + Config: ->numeric(), ->sortable(), ->toggleable(isToggledHiddenByDefault: true) + +Column: nsfw + Component: Filament\Tables\Columns\IconColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/icon + Config: ->boolean(), ->toggleable(isToggledHiddenByDefault: true) + +Column: bitrate + Component: Filament\Tables\Columns\TextColumn + Config: ->formatStateUsing(fn (?int $state): ?string => $state ? ($state / 1000) . ' kbps' : null), ->placeholder('—'), ->toggleable(isToggledHiddenByDefault: true) + +Column: user_limit + Component: Filament\Tables\Columns\TextColumn + Config: ->numeric(), ->placeholder('—'), ->toggleable(isToggledHiddenByDefault: true) + +Column: discord_channel_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->toggleable(isToggledHiddenByDefault: true) +``` + +Colunas removidas vs. scaffold: `guild.name` (só existe 1 guild — sai da +tabela; permanece na View), `topic` (vira description da coluna name). + +``` +Filter: type + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(collect(DiscordChannelType::cases())->reject(fn (DiscordChannelType $type): bool => $type === DiscordChannelType::GuildCategory)->mapWithKeys(fn (DiscordChannelType $type): array => [$type->value => $type->getLabel()])->all()), ->multiple() + +Filter: nsfw + Component: Filament\Tables\Filters\TernaryFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/ternary + Config: (padrão sim/não/todos) +``` + +## Infolist (`Schemas/DiscordChannelInfolist.php`) + +`Columns: 1` no raiz; seções `->columns(2)`. + +``` +Section: channels.sections.channel (->columns(2)) + Entry: name — TextEntry, ->weight(FontWeight::Bold) + Entry: type — TextEntry, ->badge() + Entry: topic — TextEntry, ->columnSpanFull(), ->placeholder('—') + Entry: parent.name — TextEntry (categoria), ->placeholder('—') + Entry: guild.name — TextEntry, ->url(fn (DiscordChannel $record): ?string => $record->guild ? DiscordGuildResource::getUrl('view', ['record' => $record->guild]) : null), ->color('primary') // link cruzado + Entry: discord_channel_id — TextEntry, ->copyable() + +Section: channels.sections.settings (->columns(2), ->collapsed()) + Entry: position — TextEntry, ->numeric() + Entry: nsfw — IconEntry (Filament\Infolists\Components\IconEntry), ->boolean() + Docs: https://filamentphp.com/docs/5.x/infolists/icon-entry + Entry: bitrate — TextEntry (mesmo formatStateUsing kbps), ->placeholder('—') + Entry: user_limit — TextEntry, ->placeholder('—') + Entry: created_at / updated_at — TextEntry, ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')) +``` + +**Expected behavior** +- **Dado** a listagem padrão, **então** os canais aparecem agrupados por + categoria (grupos colapsáveis), ordenados por `position`, e nenhuma linha é + uma categoria (type 4). +- **Dado** canal sem categoria (`parent_id` null), **então** cai no grupo + "Sem categoria" (chave `channels.groups.uncategorized`). +- **Dado** canal de voz, **então** badge verde "Voz" com bitrate "64 kbps"; + canal de texto mostra "—" em bitrate/user_limit. +- **Dado** busca por "panela", **então** encontra canais com emoji no nome + (busca ILIKE no `name`). + +--- + +# 8. DiscordRoleResource + +**Contexto**: 82 roles com `color` decimal (7873791 = #782BF1), hierarquia por +`position` (maior = mais alto, como no Discord). Tabela atual mostra color como +inteiro. Navegação: ícone `Heroicon::OutlinedTag`, sort `3`, group +`group_server`, labels `panel-admin::discord.roles.*`. Global search: manter. + +## Tabela (`Tables/DiscordRolesTable.php`) + +Antes / depois da cor: + +```php +// ANTES +TextColumn::make('color')->label('Color'), // renderiza "7873791" + +// DEPOIS +ColorColumn::make('color') + ->label(__('panel-admin::discord.roles.fields.color')) + ->state(fn (DiscordRole $record): ?string => $record->color > 0 + ? sprintf('#%06X', $record->color) + : null), +``` + +``` +Column: color + Component: Filament\Tables\Columns\ColorColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/color + Config: ->state(closure hex acima) // color 0 = sem cor no Discord → null → célula vazia + +Column: name + Component: Filament\Tables\Columns\TextColumn + Config: ->searchable(), ->sortable(), ->weight(FontWeight::Medium) + +Column: position + Component: Filament\Tables\Columns\TextColumn + Config: ->numeric(), ->sortable() + +Column: members_count + Component: Filament\Tables\Columns\TextColumn + Config: ->counts('members'), ->numeric(), ->sortable() + +Column: is_hoisted + Component: Filament\Tables\Columns\IconColumn + Config: ->boolean(), ->toggleable(isToggledHiddenByDefault: true) + +Column: is_mentionable + Component: Filament\Tables\Columns\IconColumn + Config: ->boolean(), ->toggleable(isToggledHiddenByDefault: true) + +Column: is_managed + Component: Filament\Tables\Columns\IconColumn + Config: ->boolean() // visível: distingue roles de bot/integração + +Column: discord_role_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->toggleable(isToggledHiddenByDefault: true) +``` + +Colunas removidas vs. scaffold: `guild.name`, `permissions` (bitfield ilegível +— fica na View, copiável), `icon` (raramente preenchido; fica na View). + +`->defaultSort('position', 'desc')` (espelha a hierarquia do Discord). + +``` +Filter: is_managed + Component: Filament\Tables\Filters\TernaryFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/ternary + +Filter: is_hoisted + Component: Filament\Tables\Filters\TernaryFilter +``` + +## Infolist (`Schemas/DiscordRoleInfolist.php`) + +`Columns: 1` raiz; seção única `->columns(2)`: + +``` +Section: roles.sections.role (->columns(2)) + Entry: color — ColorEntry (Filament\Infolists\Components\ColorEntry), ->state(mesma closure hex), ->placeholder('—') + Docs: https://filamentphp.com/docs/5.x/infolists/color-entry + Entry: name — TextEntry, ->weight(FontWeight::Bold) + Entry: position — TextEntry, ->numeric() + Entry: members_count — TextEntry, ->state(fn (DiscordRole $record): int => $record->members()->count()) + Entry: permissions — TextEntry, ->copyable(), ->helperText('Bitfield de permissões do Discord') + Entry: discord_role_id — TextEntry, ->copyable() + Entry: is_hoisted / is_mentionable / is_managed — IconEntry, ->boolean() + Entry: guild.name — TextEntry, ->url(link cruzado p/ DiscordGuildResource view), ->color('primary') +``` + +**Expected behavior** +- **Dado** o role "💜 heartdevs.com" (color 7873791), **então** a célula mostra + o swatch #782BF1. +- **Dado** role com color 0 (ex.: "Janitor"), **então** célula de cor vazia + (sem swatch preto incorreto). +- **Dado** ordenação padrão, **então** roles de topo de hierarquia primeiro + (position 81 → 0). +- **Dado** filtro is_managed = sim, **então** só roles de bots/integrações. + +--- + +# 9. DiscordMemberResource + +**Contexto**: 26.256 membros (680 saíram, 5 bots, 1.583 pending, 13 boosters, +0 vínculos com external identity em prod). Tabela atual lista tudo sem paginação +otimizada e busca global usa `externalIdentity.email` (inútil — 0 preenchidos). +Decisão: **ativos por padrão** + deferred loading. + +Navegação: ícone `Heroicon::OutlinedUsers`, sort `4`, group `group_server`, +labels `panel-admin::discord.members.*`. + +**Global search (corrigir)**: `getGloballySearchableAttributes()` → +`['username', 'global_name', 'nickname']`; `getGlobalSearchResultDetails()` +mostra `global_name` e `nickname` quando presentes; remover o eager load de +`externalIdentity` do `getGlobalSearchEloquentQuery()` (manter `guild` se usado +nos details, senão remover o override todo). + +## Tabela (`Tables/DiscordMembersTable.php`) + +Config da tabela: + +```php +->deferLoading() +->defaultSort('joined_at', 'desc') +->paginated([25, 50, 100]) +``` + +``` +Column: avatar + Component: Filament\Tables\Columns\ImageColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/image + Config: ->circular(), ->state(fn (DiscordMember $record): ?string => $record->avatar ? sprintf('https://cdn.discordapp.com/avatars/%s/%s.png?size=64', $record->discord_user_id, $record->avatar) : null), ->defaultImageUrl(fn (DiscordMember $record): string => sprintf('https://cdn.discordapp.com/embed/avatars/%d.png', ((int) $record->discord_user_id >> 22) % 6)) + +Column: username + Component: Filament\Tables\Columns\TextColumn + Config: ->searchable(), ->sortable(), ->weight(FontWeight::Medium), ->description(fn (DiscordMember $record): ?string => $record->nickname ?? $record->global_name) + +Column: joined_at + Component: Filament\Tables\Columns\TextColumn + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->sortable() + +Column: roles_count + Component: Filament\Tables\Columns\TextColumn + Config: ->counts('roles'), ->numeric(), ->sortable() + +Column: is_bot + Component: Filament\Tables\Columns\IconColumn + Config: ->boolean(), ->toggleable(isToggledHiddenByDefault: true) + +Column: premium_since + Component: Filament\Tables\Columns\TextColumn + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->sortable(), ->placeholder('—'), ->toggleable(isToggledHiddenByDefault: true) + +Column: left_at + Component: Filament\Tables\Columns\TextColumn + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->sortable(), ->placeholder('—'), ->toggleable(isToggledHiddenByDefault: true) + +Column: discord_user_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->toggleable(isToggledHiddenByDefault: true) +``` + +Colunas removidas vs. scaffold: `guild.name`, `externalIdentity.email`, +`global_name`/`nickname` (viram description do username), `avatar` texto, +`is_pending` (vira filtro), `communication_disabled_until` (View). + +``` +Filter: left_at ("Status no servidor" — o default que esconde quem saiu) + Component: Filament\Tables\Filters\TernaryFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/ternary + Config: ->label(__('panel-admin::discord.members.filters.left')), ->nullable(), ->default(false), ->trueLabel(...'saíram'), ->falseLabel(...'ativos'), ->placeholder(...'todos') + // ->nullable(): true → whereNotNull('left_at'); false → whereNull('left_at'); default(false) = ativos + +Filter: is_bot + Component: Filament\Tables\Filters\TernaryFilter + +Filter: is_pending + Component: Filament\Tables\Filters\TernaryFilter + +Filter: roles + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->relationship('roles', 'name'), ->multiple(), ->preload(), ->searchable() +``` + +## Infolist (`Schemas/DiscordMemberInfolist.php`) + +`Columns: 1` raiz. + +``` +Section: members.sections.profile (->columns(2)) + Entry: avatar — ImageEntry, ->circular(), ->state(mesma closure CDN, size=128) + defaultImageUrl + Entry: username — TextEntry, ->weight(FontWeight::Bold) + Entry: global_name — TextEntry, ->placeholder('—') + Entry: nickname — TextEntry, ->placeholder('—') + Entry: discord_user_id — TextEntry, ->copyable() + Entry: guild.name — TextEntry, ->url(link cruzado p/ guild view), ->color('primary') + +Section: members.sections.status (->columns(2)) + Entry: is_bot — IconEntry, ->boolean() + Entry: is_pending — IconEntry, ->boolean() + Entry: joined_at — TextEntry, ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')) + Entry: premium_since — TextEntry, idem, ->placeholder('—') + Entry: communication_disabled_until — TextEntry, idem, ->placeholder('—') + Entry: left_at — TextEntry, idem, ->placeholder('—') + +Section: members.sections.roles + Entry: roles.name + Component: Filament\Infolists\Components\TextEntry + Config: ->badge(), ->columnSpanFull(), ->placeholder('—') // um badge por role +``` + +**Expected behavior** +- **Dado** a listagem padrão, **então** só membros com `left_at` null aparecem + (25.576) e a tabela usa deferred loading (skeleton antes da primeira query). +- **Dado** o filtro "saíram", **então** os 680 ex-membros aparecem com `left_at` + visível ao togglar a coluna. +- **Dado** membro sem avatar custom, **então** a coluna mostra o avatar default + do Discord calculado do snowflake (`(id >> 22) % 6`), sem imagem quebrada. +- **Dado** busca "danielhe4rt" na busca global do painel, **então** encontra por + username/global_name/nickname (não mais por email de external identity). + +--- + +# 10. DiscordEventLogResource + +**Contexto**: 237k registros, insert-only pelo gateway do bot, 48 tipos de +evento. Payloads são JSON do gateway do Discord (mensagens com embeds, voice +states, audit log entries...). `guild_id`/`user_id`/`channel_id` são snowflakes +em texto (às vezes string vazia — tratar como ausente). Espelhar o +`TwitchEventLogResource`. + +Navegação: ícone `Heroicon::OutlinedQueueList`, sort `1`, group +`group_events`, labels `panel-admin::discord.event_logs.*`. Global search: +nenhum (manter `[]`, remover método se preferir — sem record title útil). +`$recordTitleAttribute = 'event_type'`. + +## Tabela (`Tables/DiscordEventLogsTable.php`) + +Config: `->deferLoading()`, `->defaultSort('created_at', 'desc')` (usa o índice +novo), `->paginated([25, 50, 100])`. + +Antes / depois da coluna principal: + +```php +// ANTES +TextColumn::make('event_type')->label('Event Type'), + +// DEPOIS +TextColumn::make('event_type') + ->label(__('panel-admin::discord.event_logs.fields.event_type')) + ->badge() + ->color(fn (string $state): string => match (true) { + str_starts_with($state, 'MESSAGE_') => 'info', + str_starts_with($state, 'GUILD_MEMBER_') || str_starts_with($state, 'GUILD_JOIN_') => 'success', + str_starts_with($state, 'GUILD_BAN_') || str_starts_with($state, 'AUTO_MODERATION_') => 'danger', + str_starts_with($state, 'VOICE_') || str_starts_with($state, 'STAGE_') => 'warning', + str_starts_with($state, 'GUILD_AUDIT_') => 'danger', + str_starts_with($state, 'CHANNEL_') || str_starts_with($state, 'THREAD_') => 'primary', + default => 'gray', + }) + ->searchable() + ->sortable(), +``` + +``` +Column: event_type — (acima) + +Column: user_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->placeholder('—'), ->formatStateUsing(fn (?string $state): ?string => $state !== '' ? $state : null) + +Column: channel_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->placeholder('—'), ->toggleable(isToggledHiddenByDefault: true) + +Column: guild_id + Component: Filament\Tables\Columns\TextColumn + Config: ->copyable(), ->toggleable(isToggledHiddenByDefault: true) // 1 guild só — ruído + +Column: created_at + Component: Filament\Tables\Columns\TextColumn + Config: ->dateTime('d/m/Y H:i:s'), ->timezone(config('app.display_timezone')), ->sortable() +``` + +Coluna `payload` **sai da lista** (JSON gigante — só na View). + +``` +Filter: event_type + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(fn (): array => DiscordEventLog::query()->select('event_type')->distinct()->orderBy('event_type')->pluck('event_type', 'event_type')->all()), ->multiple(), ->searchable() + // distinct usa o índice de event_type (index-only scan) — barato + +Filter: created_at (período) + Component: Filament\Tables\Filters\Filter + Docs: https://filamentphp.com/docs/5.x/tables/filters/custom + Form: Filament\Forms\Components\DatePicker::make('from') + DatePicker::make('until') + Config: ->schema([...]), ->query(fn (Builder $query, array $data): Builder => $query + ->when($data['from'] ?? null, fn (Builder $query, string $date): Builder => $query->where('created_at', '>=', $date)) + ->when($data['until'] ?? null, fn (Builder $query, string $date): Builder => $query->where('created_at', '<=', $date . ' 23:59:59'))) +``` + +## Infolist (`Schemas/DiscordEventLogInfolist.php`) + +`Columns: 1` raiz. Espelho do TwitchEventLogResource::infolist(). + +``` +Section: event_logs.sections.event (->columns(2)) + Entry: event_type — TextEntry, ->badge() (mesma closure de cor da tabela) + Entry: created_at — TextEntry, ->dateTime('d/m/Y H:i:s'), ->timezone(config('app.display_timezone')) + Entry: guild_id — TextEntry, ->copyable(), ->placeholder('—') + Entry: channel_id — TextEntry, ->copyable(), ->placeholder('—') + Entry: user_id (link cruzado — resolve 1 query só na View, nunca na lista) + Component: Filament\Infolists\Components\TextEntry + Config: ->copyable(), ->placeholder('—'), ->url(fn (DiscordEventLog $record): ?string => filled($record->user_id) && ($member = DiscordMember::query()->where('discord_user_id', $record->user_id)->first()) ? DiscordMemberResource::getUrl('view', ['record' => $member]) : null), ->color(fn (DiscordEventLog $record): string => filled($record->user_id) ? 'primary' : 'gray') + +Section: event_logs.sections.payload + Entry: payload + Component: Filament\Infolists\Components\CodeEntry + Docs: https://filamentphp.com/docs/5.x/infolists/code-entry + Config: ->formatStateUsing(fn (array $state): string => json_encode($state, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)), ->extraAttributes(['class' => 'overflow-auto max-h-128']), ->columnSpanFull() +``` + +**Expected behavior** +- **Dado** a listagem padrão, **então** ordena por `created_at desc` via índice + (sem seq scan) e `MESSAGE_CREATE` aparece como badge azul, `GUILD_BAN_ADD` + vermelha, `VOICE_STATE_UPDATE` amarela. +- **Dado** filtro tipo + período combinados, **então** a query combina os dois + índices e retorna sem timeout. +- **Dado** um evento com `user_id` vazio (`''`, comum em GUILD_MEMBER_UPDATE), + **então** a célula mostra "—" e o link cruzado não renderiza. +- **Dado** a View de um MESSAGE_CREATE, **então** o payload aparece como JSON + identado com scroll (não estoura a página) e o user_id linka para o membro + quando ele existe em `discord_members`. + +--- + +# 11. Dashboard + Widgets + +**Contexto**: o cluster não tem página de overview. Copiar a anatomia do +`TwitchDashboard` (page + blade vazio + widgets header/footer). + +## Página + +``` +Page: DiscordDashboard + Location: He4rt\PanelAdmin\Discord\Pages\DiscordDashboard + Docs: https://filamentphp.com/docs/5.x/navigation/custom-pages + Cluster: DiscordCluster + Slug: dashboard + NavigationSort: 0 + NavigationIcon: Heroicon::OutlinedChartBar + NavigationGroup: __('panel-admin::discord.navigation.group_overview') + NavigationLabel: __('panel-admin::discord.navigation.dashboard') + MaxContentWidth: Width::Full (Filament\Support\Enums\Width) + View: 'panel-admin::discord.dashboard' + HeaderWidgets: [DiscordStatsWidget] + FooterWidgets: [EventsPerDayChartWidget, MemberGrowthChartWidget] + getFooterWidgetsColumns(): 2 +``` + +Blade: `app-modules/panel-admin/resources/views/discord/dashboard.blade.php` +com o mesmo conteúdo do twitch: ` `. + +Layout resultante: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [👥 Membros ativos] [→ Entradas 7d] [← Saídas 7d] │ +│ [⚡ Eventos 24h] [✨ Boosters] [# Canais] │ +├──────────────────────────────┬───────────────────────────────┤ +│ Eventos por dia (line, 30d) │ Crescimento de membros (bar) │ +│ ╱╲ ╱╲ │ ██ joins ░░ leaves │ +│ ___╱ ╲__╱ ╲___ │ ██░ ██░ █░ ██░ │ +└──────────────────────────────┴───────────────────────────────┘ +``` + +## Widget: DiscordStatsWidget + +``` +Widget: DiscordStatsWidget + Type: Filament\Widgets\StatsOverviewWidget + Docs: https://filamentphp.com/docs/5.x/widgets/stats-overview + Location: He4rt\PanelAdmin\Discord\Widgets\DiscordStatsWidget + Properties: protected ?string $pollingInterval = null; protected int|string|array $columnSpan = 'full'; + Imports: Filament\Widgets\StatsOverviewWidget\Stat, Filament\Support\Colors\Color, Filament\Support\Icons\Heroicon + + Stats (labels/descrições via panel-admin::discord.dashboard.stats.*): + Stat: Membros ativos + Value: DiscordMember::query()->whereNull('left_at')->where('is_bot', false)->count() + Icon: Heroicon::OutlinedUsers, Color: Color::Blue + Stat: Entradas (7d) + Value: DiscordMember::query()->where('joined_at', '>=', now()->subDays(7))->count() + Icon: Heroicon::OutlinedArrowRightEndOnRectangle, Color: Color::Green + Stat: Saídas (7d) + Value: DiscordMember::query()->where('left_at', '>=', now()->subDays(7))->count() + Icon: Heroicon::OutlinedArrowLeftStartOnRectangle, Color: Color::Red + Stat: Eventos (24h) + Value: DiscordEventLog::query()->where('created_at', '>=', now()->subDay())->count() // usa índice novo + Icon: Heroicon::OutlinedBolt, Color: Color::Amber + Stat: Boosters + Value: DiscordMember::query()->whereNotNull('premium_since')->whereNull('left_at')->count() + Icon: Heroicon::OutlinedSparkles, Color: Color::Fuchsia + Stat: Canais + Value: DiscordChannel::query()->where('type', '!=', DiscordChannelType::GuildCategory)->count() + Icon: Heroicon::OutlinedHashtag, Color: Color::Gray +``` + +## Widget: EventsPerDayChartWidget + +``` +Widget: EventsPerDayChartWidget + Type: Filament\Widgets\ChartWidget + Docs: https://filamentphp.com/docs/5.x/widgets/charts + Location: He4rt\PanelAdmin\Discord\Widgets\EventsPerDayChartWidget + ChartType: line + Heading: __('panel-admin::discord.dashboard.events_per_day') + Data: cópia estrutural do He4rt\PanelAdmin\Twitch\Widgets\EventsPerDayChartWidget — + DiscordEventLog, últimos 30 dias, selectRaw('DATE(created_at) as date, COUNT(*) as total'), + preenchendo dias sem eventos com 0. + Cores do dataset: borderColor '#782bf1' (roxo He4rt), backgroundColor 'rgba(120, 43, 241, 0.1)', fill true, tension 0.3 +``` + +## Widget: MemberGrowthChartWidget + +``` +Widget: MemberGrowthChartWidget + Type: Filament\Widgets\ChartWidget + Location: He4rt\PanelAdmin\Discord\Widgets\MemberGrowthChartWidget + ChartType: bar + Heading: __('panel-admin::discord.dashboard.member_growth.heading') + + Data (fonte: discord_members, não event logs — sobrevive a prune futuro): + $start = now()->subDays(29)->startOfDay(); + joins: DiscordMember::query()->where('joined_at', '>=', $start)->selectRaw('DATE(joined_at) as date, COUNT(*) as total')->groupBy('date')->pluck('total', 'date') + leaves: DiscordMember::query()->where('left_at', '>=', $start)->selectRaw('DATE(left_at) as date, COUNT(*) as total')->groupBy('date')->pluck('total', 'date') + Loop de 30 dias preenchendo zeros (mesmo padrão do widget da Twitch). + + Datasets: + - label __('...member_growth.joins'), data joins, backgroundColor 'rgba(34, 197, 94, 0.7)' + - label __('...member_growth.leaves'), data leaves (valores negativos: * -1, para efeito espelhado), backgroundColor 'rgba(239, 68, 68, 0.7)' + Options: stacked true nos eixos x/y (getOptions() retornando ['scales' => ['x' => ['stacked' => true], 'y' => ['stacked' => true]]]) +``` + +**Expected behavior** +- **Dado** o dashboard aberto, **então** os 6 stats carregam com uma query cada + (todas count com filtros indexados ou tabelas pequenas). +- **Dado** um dia sem eventos, **então** o chart mostra 0 (não pula o dia). +- **Dado** dark mode, **então** nada quebra (widgets nativos do Filament — sem + bg hardcoded; o blade custom é vazio). + +# 12. Autorização + +``` +Todos os resources e o dashboard: Authorization: todos os usuários autenticados +no painel admin (o acesso ao painel já é restrito por config('he4rt.admins') — +ver AdminPanelAccessTest). Sem policies novas. O read-only é estrutural: não +existem rotas create/edit nem actions de escrita. +``` + +# 13. Testes (smoke) + +**Contexto**: não existe teste para o cluster Discord. Padrão de setup copiado +de `app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php`. + +Arquivo: `app-modules/panel-admin/tests/Feature/Discord/DiscordClusterSmokeTest.php` + +Setup (beforeEach): + +```php +use function Pest\Livewire\livewire; + +beforeEach(function (): void { + $user = User::factory()->create(['username' => 'danielhe4rt']); + config(['he4rt.admins' => 'danielhe4rt']); + $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); +``` + +Casos (todos com dados de factory, usando `->recycle($guild)` para propagar a +guild): + +1. `lista as guilds` — cria `DiscordGuild::factory()`, `livewire(ListDiscordGuilds::class)->loadTable()->assertCanSeeTableRecords([...])` (loadTable por causa do deferLoading onde houver). +2. `exibe a view da guild` — `livewire(ViewDiscordGuild::class, ['record' => $guild->id])->assertOk()`. +3. `lista canais agrupados sem categorias` — cria 1 categoria + 2 canais filhos; assertCanSeeTableRecords nos filhos e `assertCanNotSeeTableRecords([$categoria])`. +4. `exibe a view do canal` — assertOk. +5. `lista membros ativos por padrão` — cria 1 membro ativo + 1 com `left_at`; vê o ativo, não vê o que saiu; `filterTable('left_at', true)` inverte. +6. `exibe a view do membro` — assertOk. +7. `lista roles ordenadas por position desc` — 2 roles, assertCanSeeTableRecords in order. +8. `exibe a view do role` — assertOk (incluir role com color 0 para cobrir o placeholder). +9. `lista event logs` — `DiscordEventLog::factory()->count(2)`, loadTable, assertCanSeeTableRecords. +10. `filtra event logs por tipo` — `filterTable('event_type', ['GUILD_BAN_ADD'])`. +11. `exibe a view do event log com payload` — assertOk. +12. `dashboard renderiza` — `livewire(DiscordDashboard::class)->assertOk()` e cada widget: `livewire(DiscordStatsWidget::class)->assertOk()` etc. +13. `relation managers da guild renderizam` — padrão: + +```php +livewire(ChannelsRelationManager::class, [ + 'ownerRecord' => $guild, + 'pageClass' => ViewDiscordGuild::class, +])->assertOk(); +``` + +Rodar: `php artisan test --compact --filter=DiscordClusterSmokeTest` e depois a +suíte do módulo panel-admin + integration-discord (a migration). + +# 14. Ordem de implementação e verificação final + +1. Migration (índice) + `php artisan migrate`. +2. Enum `DiscordChannelType` (contratos) — roda PHPStan cedo, pega call sites. +3. Factory + model `DiscordEventLog`. +4. Lang files (en + pt_BR). +5. Cluster (ícone/labels). +6. Resources na ordem: EventLogs (espelho fiel do Twitch, valida o padrão) → + Guild → Channels → Roles → Members. +7. Relation managers da Guild. +8. Dashboard + widgets + blade. +9. Smoke tests. +10. `vendor/bin/pint --dirty --format agent` && `vendor/bin/phpstan analyse` && + `php artisan test --compact` (módulos panel-admin e integration-discord). + +## Checklist do plano (auto-revisão) + +- [x] Namespaces completos em todo componente (colunas, filtros, entries, actions, widgets). +- [x] Docs URLs (5.x) em cada tipo de componente usado. +- [x] Sem componentes inexistentes (BadgeColumn/BooleanColumn → TextColumn->badge()/IconColumn->boolean()). +- [x] Layout: infolists com `Columns: 1` no raiz + seções `->columns(2)` (largura efetiva 50%). +- [x] Read-only: nenhum form spec (não há create/edit) — validação N/A. +- [x] Autorização declarada explicitamente (sem policies novas). +- [x] Timezone: todo dateTime com `config('app.display_timezone')`. +- [x] `toolbarActions` removidas (não há bulk actions para preservar). +- [x] Comando de migration com `--module`; colunas de data intocadas (sem Tz a corrigir). diff --git a/app-modules/panel-admin/lang/en/discord.php b/app-modules/panel-admin/lang/en/discord.php new file mode 100644 index 000000000..c8d39cf01 --- /dev/null +++ b/app-modules/panel-admin/lang/en/discord.php @@ -0,0 +1,182 @@ + [ + 'cluster' => 'Discord', + 'cluster_breadcrumb' => 'Discord', + 'group_overview' => 'Overview', + 'group_server' => 'Server', + 'group_events' => 'Events', + 'dashboard' => 'Dashboard', + 'guilds' => 'Guilds', + 'channels' => 'Channels', + 'roles' => 'Roles', + 'members' => 'Members', + 'event_logs' => 'Event Logs', + ], + + 'guilds' => [ + 'label' => 'Guild', + 'plural' => 'Guilds', + 'fields' => [ + 'icon' => 'Icon', + 'name' => 'Name', + 'description' => 'Description', + 'discord_guild_id' => 'Guild ID', + 'member_count' => 'Members', + 'premium_tier' => 'Boost tier', + 'channels_count' => 'Channels', + 'roles_count' => 'Roles', + 'synced_at' => 'Last synced', + 'features' => 'Features', + ], + 'sections' => [ + 'overview' => 'Overview', + 'features' => 'Features', + ], + ], + + 'channels' => [ + 'label' => 'Channel', + 'plural' => 'Channels', + 'fields' => [ + 'name' => 'Name', + 'type' => 'Type', + 'topic' => 'Topic', + 'category' => 'Category', + 'guild' => 'Guild', + 'position' => 'Position', + 'nsfw' => 'Age-restricted', + 'bitrate' => 'Bitrate', + 'user_limit' => 'User limit', + 'discord_channel_id' => 'Channel ID', + 'created_at' => 'Created at', + 'updated_at' => 'Updated at', + ], + 'filters' => [ + 'type' => 'Type', + 'nsfw' => 'Age-restricted', + ], + 'groups' => [ + 'uncategorized' => 'Uncategorized', + ], + 'sections' => [ + 'channel' => 'Channel', + 'settings' => 'Settings', + ], + ], + + 'members' => [ + 'label' => 'Member', + 'plural' => 'Members', + 'fields' => [ + 'avatar' => 'Avatar', + 'username' => 'Username', + 'global_name' => 'Display name', + 'nickname' => 'Nickname', + 'joined_at' => 'Joined at', + 'roles_count' => 'Roles', + 'is_bot' => 'Bot', + 'premium_since' => 'Boosting since', + 'left_at' => 'Left at', + 'discord_user_id' => 'User ID', + 'guild' => 'Guild', + 'is_pending' => 'Pending verification', + 'communication_disabled_until' => 'Timed out until', + 'roles' => 'Roles', + ], + 'filters' => [ + 'left' => [ + 'label' => 'Server status', + 'true' => 'Left', + 'false' => 'Active', + 'placeholder' => 'All', + ], + 'is_bot' => 'Bot', + 'is_pending' => 'Pending verification', + 'roles' => 'Roles', + ], + 'sections' => [ + 'profile' => 'Profile', + 'status' => 'Status', + 'roles' => 'Roles', + ], + ], + + 'roles' => [ + 'label' => 'Role', + 'plural' => 'Roles', + 'fields' => [ + 'color' => 'Color', + 'name' => 'Name', + 'position' => 'Position', + 'members_count' => 'Members', + 'is_hoisted' => 'Shown separately', + 'is_mentionable' => 'Mentionable', + 'is_managed' => 'Managed by integration', + 'discord_role_id' => 'Role ID', + 'permissions' => 'Permissions', + 'permissions_helper' => 'Discord permissions bitfield', + 'guild' => 'Guild', + ], + 'filters' => [ + 'is_managed' => 'Managed by integration', + 'is_hoisted' => 'Shown separately', + ], + 'sections' => [ + 'role' => 'Role', + ], + ], + + 'event_logs' => [ + 'label' => 'Event Log', + 'plural' => 'Event Logs', + 'fields' => [ + 'event_type' => 'Event type', + 'user_id' => 'User ID', + 'channel_id' => 'Channel ID', + 'guild_id' => 'Guild ID', + 'created_at' => 'Occurred at', + 'payload' => 'Payload', + ], + 'filters' => [ + 'event_type' => 'Event type', + 'period' => [ + 'label' => 'Period', + 'from' => 'From', + 'until' => 'Until', + ], + ], + 'sections' => [ + 'event' => 'Event', + 'payload' => 'Payload', + ], + ], + + 'dashboard' => [ + 'heading' => 'Discord Overview', + 'stats' => [ + 'active_members' => 'Active members', + 'active_members_desc' => 'currently in the server', + 'joins_7d' => 'Joins (7d)', + 'joins_7d_desc' => 'last 7 days', + 'leaves_7d' => 'Leaves (7d)', + 'leaves_7d_desc' => 'last 7 days', + 'events_24h' => 'Events (24h)', + 'events_24h_desc' => 'last 24 hours', + 'boosters' => 'Boosters', + 'boosters_desc' => 'active boosts', + 'channels' => 'Channels', + 'channels_desc' => 'excluding categories', + ], + 'events_per_day' => 'Events per Day', + 'events_per_day_label' => 'Events', + 'member_growth' => [ + 'heading' => 'Member Growth', + 'joins' => 'Joins', + 'leaves' => 'Leaves', + ], + ], +]; diff --git a/app-modules/panel-admin/lang/en/events.php b/app-modules/panel-admin/lang/en/events.php index 865227d09..667fa3507 100644 --- a/app-modules/panel-admin/lang/en/events.php +++ b/app-modules/panel-admin/lang/en/events.php @@ -10,7 +10,6 @@ 'title' => 'Title', 'slug' => 'Slug', 'type' => 'Type', - 'tenant' => 'Tenant', 'location' => 'Location', 'description' => 'Description', 'starts_at' => 'Starts At', diff --git a/app-modules/panel-admin/lang/en/location.php b/app-modules/panel-admin/lang/en/location.php new file mode 100644 index 000000000..8f121ca58 --- /dev/null +++ b/app-modules/panel-admin/lang/en/location.php @@ -0,0 +1,31 @@ + [ + 'title' => 'Location', + ], + + 'stats' => [ + 'web_active' => 'Active on the web', + 'discord_active' => 'Active on Discord', + 'located' => 'Members with location', + 'coverage' => ':percent% of members', + 'states' => 'States reached', + 'states_hint' => 'Brazilian federative units', + 'vs_previous' => '% vs previous period', + ], + + 'map' => [ + 'heading' => 'Members by state', + 'hint' => 'Color proportional to the number of members', + ], + + 'top' => [ + 'heading' => 'Top 5 states', + 'hint' => 'Share of members with a registered location', + 'others' => ':count other states', + 'empty' => 'No location data yet.', + ], +]; diff --git a/app-modules/panel-admin/lang/en/twitch.php b/app-modules/panel-admin/lang/en/twitch.php index 65224e856..cbc33f730 100644 --- a/app-modules/panel-admin/lang/en/twitch.php +++ b/app-modules/panel-admin/lang/en/twitch.php @@ -19,6 +19,11 @@ 'plural' => 'Event Logs', ], + 'connect' => [ + 'connect' => 'Connect Twitch', + 'reconnect' => 'Reconnect Twitch (:login)', + ], + 'subscriptions' => [ 'label' => 'Subscription', 'plural' => 'Subscriptions', @@ -26,7 +31,9 @@ 'register' => 'Register Subscriptions', 'register_confirm_button' => 'Register All', 'registered' => 'Subscriptions registered successfully.', + 'register_partial' => 'Some subscriptions failed to register.', 'register_failed' => 'Failed to register subscriptions.', + 'no_broadcaster' => 'Connect your Twitch account first — use the "Connect Twitch" button above.', 'sync' => 'Sync from Twitch', 'synced' => 'Subscriptions synced successfully.', 'sync_failed' => 'Failed to sync subscriptions from Twitch.', diff --git a/app-modules/panel-admin/lang/pt_BR/discord.php b/app-modules/panel-admin/lang/pt_BR/discord.php new file mode 100644 index 000000000..29633f83b --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/discord.php @@ -0,0 +1,182 @@ + [ + 'cluster' => 'Discord', + 'cluster_breadcrumb' => 'Discord', + 'group_overview' => 'Visão geral', + 'group_server' => 'Servidor', + 'group_events' => 'Eventos', + 'dashboard' => 'Dashboard', + 'guilds' => 'Guilds', + 'channels' => 'Canais', + 'roles' => 'Cargos', + 'members' => 'Membros', + 'event_logs' => 'Logs de eventos', + ], + + 'guilds' => [ + 'label' => 'Guild', + 'plural' => 'Guilds', + 'fields' => [ + 'icon' => 'Ícone', + 'name' => 'Nome', + 'description' => 'Descrição', + 'discord_guild_id' => 'ID da guild', + 'member_count' => 'Membros', + 'premium_tier' => 'Nível de boost', + 'channels_count' => 'Canais', + 'roles_count' => 'Cargos', + 'synced_at' => 'Última sincronização', + 'features' => 'Recursos', + ], + 'sections' => [ + 'overview' => 'Visão geral', + 'features' => 'Recursos', + ], + ], + + 'channels' => [ + 'label' => 'Canal', + 'plural' => 'Canais', + 'fields' => [ + 'name' => 'Nome', + 'type' => 'Tipo', + 'topic' => 'Tópico', + 'category' => 'Categoria', + 'guild' => 'Guild', + 'position' => 'Posição', + 'nsfw' => 'Conteúdo sensível', + 'bitrate' => 'Taxa de bits', + 'user_limit' => 'Limite de usuários', + 'discord_channel_id' => 'ID do canal', + 'created_at' => 'Criado em', + 'updated_at' => 'Atualizado em', + ], + 'filters' => [ + 'type' => 'Tipo', + 'nsfw' => 'Conteúdo sensível', + ], + 'groups' => [ + 'uncategorized' => 'Sem categoria', + ], + 'sections' => [ + 'channel' => 'Canal', + 'settings' => 'Configurações', + ], + ], + + 'members' => [ + 'label' => 'Membro', + 'plural' => 'Membros', + 'fields' => [ + 'avatar' => 'Avatar', + 'username' => 'Usuário', + 'global_name' => 'Nome de exibição', + 'nickname' => 'Apelido', + 'joined_at' => 'Entrou em', + 'roles_count' => 'Cargos', + 'is_bot' => 'Bot', + 'premium_since' => 'Boost desde', + 'left_at' => 'Saiu em', + 'discord_user_id' => 'ID do usuário', + 'guild' => 'Guild', + 'is_pending' => 'Verificação pendente', + 'communication_disabled_until' => 'Silenciado até', + 'roles' => 'Cargos', + ], + 'filters' => [ + 'left' => [ + 'label' => 'Status no servidor', + 'true' => 'Saíram', + 'false' => 'Ativos', + 'placeholder' => 'Todos', + ], + 'is_bot' => 'Bot', + 'is_pending' => 'Verificação pendente', + 'roles' => 'Cargos', + ], + 'sections' => [ + 'profile' => 'Perfil', + 'status' => 'Status', + 'roles' => 'Cargos', + ], + ], + + 'roles' => [ + 'label' => 'Cargo', + 'plural' => 'Cargos', + 'fields' => [ + 'color' => 'Cor', + 'name' => 'Nome', + 'position' => 'Posição', + 'members_count' => 'Membros', + 'is_hoisted' => 'Exibido separadamente', + 'is_mentionable' => 'Mencionável', + 'is_managed' => 'Gerenciado por integração', + 'discord_role_id' => 'ID do cargo', + 'permissions' => 'Permissões', + 'permissions_helper' => 'Bitfield de permissões do Discord', + 'guild' => 'Guild', + ], + 'filters' => [ + 'is_managed' => 'Gerenciado por integração', + 'is_hoisted' => 'Exibido separadamente', + ], + 'sections' => [ + 'role' => 'Cargo', + ], + ], + + 'event_logs' => [ + 'label' => 'Log de evento', + 'plural' => 'Logs de eventos', + 'fields' => [ + 'event_type' => 'Tipo de evento', + 'user_id' => 'ID do usuário', + 'channel_id' => 'ID do canal', + 'guild_id' => 'ID da guild', + 'created_at' => 'Ocorrido em', + 'payload' => 'Payload', + ], + 'filters' => [ + 'event_type' => 'Tipo de evento', + 'period' => [ + 'label' => 'Período', + 'from' => 'De', + 'until' => 'Até', + ], + ], + 'sections' => [ + 'event' => 'Evento', + 'payload' => 'Payload', + ], + ], + + 'dashboard' => [ + 'heading' => 'Visão geral do Discord', + 'stats' => [ + 'active_members' => 'Membros ativos', + 'active_members_desc' => 'atualmente no servidor', + 'joins_7d' => 'Entradas (7d)', + 'joins_7d_desc' => 'últimos 7 dias', + 'leaves_7d' => 'Saídas (7d)', + 'leaves_7d_desc' => 'últimos 7 dias', + 'events_24h' => 'Eventos (24h)', + 'events_24h_desc' => 'últimas 24 horas', + 'boosters' => 'Boosters', + 'boosters_desc' => 'boosts ativos', + 'channels' => 'Canais', + 'channels_desc' => 'excluindo categorias', + ], + 'events_per_day' => 'Eventos por dia', + 'events_per_day_label' => 'Eventos', + 'member_growth' => [ + 'heading' => 'Crescimento de membros', + 'joins' => 'Entradas', + 'leaves' => 'Saídas', + ], + ], +]; diff --git a/app-modules/panel-admin/lang/pt_BR/events.php b/app-modules/panel-admin/lang/pt_BR/events.php index e4ee75ed0..fff2a789f 100644 --- a/app-modules/panel-admin/lang/pt_BR/events.php +++ b/app-modules/panel-admin/lang/pt_BR/events.php @@ -10,7 +10,6 @@ 'title' => 'Título', 'slug' => 'Slug', 'type' => 'Tipo', - 'tenant' => 'Tenant', 'location' => 'Local', 'description' => 'Descrição', 'starts_at' => 'Início', diff --git a/app-modules/panel-admin/lang/pt_BR/location.php b/app-modules/panel-admin/lang/pt_BR/location.php new file mode 100644 index 000000000..c697d2df4 --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/location.php @@ -0,0 +1,31 @@ + [ + 'title' => 'Localização', + ], + + 'stats' => [ + 'web_active' => 'Ativos na web', + 'discord_active' => 'Ativos no Discord', + 'located' => 'Membros com localização', + 'coverage' => ':percent% dos membros', + 'states' => 'Estados alcançados', + 'states_hint' => 'Unidades federativas do Brasil', + 'vs_previous' => '% vs período anterior', + ], + + 'map' => [ + 'heading' => 'Membros por estado', + 'hint' => 'Cor proporcional ao número de membros', + ], + + 'top' => [ + 'heading' => 'Top 5 estados', + 'hint' => 'Participação dos membros com localização cadastrada', + 'others' => 'Outros :count estados', + 'empty' => 'Ainda não há dados de localização.', + ], +]; diff --git a/app-modules/panel-admin/resources/geo/SOURCE.md b/app-modules/panel-admin/resources/geo/SOURCE.md new file mode 100644 index 000000000..f497b6e5a --- /dev/null +++ b/app-modules/panel-admin/resources/geo/SOURCE.md @@ -0,0 +1,16 @@ +# br-states.geojson + +Malha dos estados brasileiros (27 UFs) usada no choropleth do painel de localização +(`Marketing → Localização`). + +- **Fonte:** IBGE — malha territorial por UF, qualidade `intermediaria` + (`https://servicodados.ibge.gov.br/api/v3/malhas/paises/BR?intrarregiao=UF`). + Dado aberto / domínio público. +- **Processamento:** geometria simplificada (topojson-simplify, ~35% dos vértices) e + coordenadas arredondadas para 3 casas decimais para reduzir o payload; propriedades + reescritas de `codarea` (código IBGE) para `{ name, uf }`. +- **Uso:** apenas geometria de desenho. Os nomes canônicos dos estados e as contagens + vêm do domínio (`App\Geo\Support\GeoLocation` + tabela `addresses`), não deste arquivo. + +Não usamos a coleção de mapas da Highsoft/Highcharts (proprietária) — a geometria aqui é +de fonte livre para casar com o renderizador `chartjs-chart-geo` (MIT). diff --git a/app-modules/panel-admin/resources/geo/br-states.geojson b/app-modules/panel-admin/resources/geo/br-states.geojson new file mode 100644 index 000000000..dadddcdf6 --- /dev/null +++ b/app-modules/panel-admin/resources/geo/br-states.geojson @@ -0,0 +1,32 @@ +{ +"type": "FeatureCollection", +"features": [ +{"type":"Feature","properties":{"uf":"RO","name":"Rondônia"},"geometry":{"type":"Polygon","coordinates":[[[-60.709,-13.693],[-60.88,-13.617],[-60.919,-13.548],[-61.041,-13.508],[-61.248,-13.496],[-61.47,-13.555],[-61.586,-13.501],[-61.757,-13.541],[-61.869,-13.528],[-61.887,-13.441],[-62.116,-13.258],[-62.115,-13.164],[-62.215,-13.111],[-62.352,-13.143],[-62.443,-13.08],[-62.613,-13.041],[-62.661,-12.969],[-62.734,-13.022],[-62.892,-12.859],[-63.007,-12.84],[-63.095,-12.684],[-63.163,-12.635],[-63.242,-12.691],[-63.384,-12.653],[-63.437,-12.564],[-63.502,-12.567],[-63.621,-12.473],[-63.786,-12.428],[-63.873,-12.447],[-63.963,-12.53],[-64.057,-12.492],[-64.174,-12.501],[-64.234,-12.455],[-64.406,-12.447],[-64.499,-12.367],[-64.513,-12.223],[-64.552,-12.242],[-64.713,-12.174],[-64.698,-12.104],[-64.76,-12.102],[-64.84,-12.011],[-64.917,-12.028],[-65.029,-11.998],[-65.019,-11.892],[-65.071,-11.856],[-65.091,-11.708],[-65.205,-11.749],[-65.257,-11.696],[-65.218,-11.652],[-65.212,-11.53],[-65.305,-11.502],[-65.36,-11.258],[-65.351,-11.122],[-65.289,-11.094],[-65.251,-10.984],[-65.276,-10.871],[-65.362,-10.802],[-65.344,-10.7],[-65.405,-10.641],[-65.43,-10.481],[-65.392,-10.374],[-65.289,-10.22],[-65.329,-10.043],[-65.329,-9.921],[-65.287,-9.84],[-65.357,-9.72],[-65.443,-9.669],[-65.513,-9.793],[-65.624,-9.836],[-65.685,-9.749],[-65.871,-9.753],[-66.033,-9.789],[-66.152,-9.786],[-66.358,-9.864],[-66.627,-9.898],[-66.81,-9.818],[-66.775,-9.753],[-66.501,-9.634],[-66.392,-9.5],[-66.375,-9.426],[-66.26,-9.411],[-65.97,-9.413],[-65.825,-9.53],[-65.8,-9.585],[-65.73,-9.563],[-65.679,-9.451],[-65.588,-9.414],[-65.503,-9.47],[-65.432,-9.459],[-65.44,-9.312],[-65.377,-9.324],[-65.247,-9.258],[-65.212,-9.279],[-65.143,-9.447],[-65.053,-9.402],[-64.92,-9.22],[-64.94,-9.125],[-64.871,-9.012],[-64.808,-8.986],[-64.611,-9.016],[-64.323,-8.929],[-64.311,-8.996],[-64.142,-8.945],[-64.158,-8.843],[-64.125,-8.717],[-64.035,-8.694],[-63.986,-8.586],[-63.925,-8.575],[-63.973,-8.478],[-63.944,-8.331],[-63.871,-8.289],[-63.791,-8.333],[-63.723,-8.249],[-63.622,-8.03],[-63.621,-7.976],[-62.867,-7.976],[-62.692,-8.093],[-62.655,-8.229],[-62.585,-8.272],[-62.518,-8.385],[-62.465,-8.34],[-62.361,-8.398],[-62.336,-8.609],[-62.269,-8.576],[-62.184,-8.599],[-62.125,-8.802],[-62.031,-8.8],[-61.979,-8.873],[-61.86,-8.853],[-61.841,-8.739],[-61.775,-8.748],[-61.713,-8.688],[-61.626,-8.729],[-61.583,-8.799],[-61.469,-8.92],[-61.556,-9.092],[-61.525,-9.243],[-61.6,-9.256],[-61.628,-9.352],[-61.559,-9.385],[-61.582,-9.459],[-61.508,-9.54],[-61.477,-9.63],[-61.532,-9.712],[-61.508,-9.861],[-61.601,-10.15],[-61.577,-10.249],[-61.462,-10.42],[-61.499,-10.699],[-61.48,-10.788],[-61.525,-10.856],[-61.55,-10.986],[-61.001,-10.992],[-60.46,-10.99],[-60.392,-11.094],[-60.301,-11.057],[-60.191,-11.114],[-60.075,-11.105],[-59.995,-11.148],[-59.959,-11.295],[-59.917,-11.338],[-59.934,-11.424],[-60.115,-11.591],[-60.103,-11.819],[-60.066,-11.891],[-59.984,-11.915],[-59.962,-12.056],[-59.9,-12.117],[-59.887,-12.245],[-59.779,-12.341],[-59.844,-12.467],[-59.948,-12.599],[-60.029,-12.619],[-60.079,-12.881],[-60.117,-12.96],[-60.183,-12.968],[-60.284,-13.085],[-60.372,-13.319],[-60.388,-13.455],[-60.511,-13.504],[-60.686,-13.625],[-60.709,-13.693]]]}}, +{"type":"Feature","properties":{"uf":"AC","name":"Acre"},"geometry":{"type":"Polygon","coordinates":[[[-73.802,-7.112],[-72.585,-7.552],[-70.055,-7.845],[-68.647,-9.049],[-67.517,-9.561],[-67.325,-9.592],[-66.81,-9.818],[-66.627,-9.898],[-66.871,-10.081],[-67.01,-10.252],[-67.17,-10.338],[-67.316,-10.319],[-67.32,-10.382],[-67.424,-10.391],[-67.436,-10.448],[-67.605,-10.534],[-67.704,-10.7],[-67.828,-10.651],[-68.002,-10.648],[-68.105,-10.715],[-68.104,-10.773],[-68.279,-10.989],[-68.487,-11.051],[-68.543,-11.111],[-68.716,-11.146],[-68.811,-10.994],[-68.912,-11.022],[-68.966,-10.988],[-69.233,-10.94],[-69.423,-10.927],[-69.719,-10.975],[-69.812,-10.926],[-69.949,-10.925],[-70.202,-11.054],[-70.308,-11.071],[-70.421,-11.039],[-70.53,-10.935],[-70.621,-11],[-70.621,-9.832],[-70.527,-9.726],[-70.598,-9.606],[-70.495,-9.425],[-70.57,-9.432],[-70.79,-9.632],[-70.869,-9.665],[-70.998,-9.818],[-71.163,-9.875],[-71.211,-9.966],[-71.377,-10],[-72.18,-10],[-72.141,-9.877],[-72.153,-9.797],[-72.271,-9.745],[-72.247,-9.656],[-72.282,-9.55],[-72.366,-9.49],[-72.524,-9.489],[-72.72,-9.412],[-73.212,-9.412],[-73.022,-9.226],[-72.94,-9.069],[-72.995,-8.92],[-73.056,-8.907],[-73.142,-8.723],[-73.285,-8.666],[-73.344,-8.602],[-73.332,-8.475],[-73.537,-8.345],[-73.627,-8.021],[-73.746,-7.953],[-73.682,-7.79],[-73.822,-7.717],[-73.99,-7.536],[-73.919,-7.465],[-73.966,-7.352],[-73.876,-7.382],[-73.747,-7.344],[-73.699,-7.296],[-73.802,-7.112]]]}}, +{"type":"Feature","properties":{"uf":"AM","name":"Amazonas"},"geometry":{"type":"Polygon","coordinates":[[[-58.895,0.264],[-58.895,-0.011],[-58.864,-0.078],[-58.869,-0.347],[-58.725,-0.438],[-58.706,-0.68],[-58.434,-0.892],[-58.428,-1.025],[-58.317,-1.148],[-58.258,-1.131],[-58.163,-1.23],[-58.059,-1.106],[-57.969,-1.163],[-57.999,-1.331],[-57.96,-1.397],[-57.825,-1.438],[-57.718,-1.503],[-57.689,-1.587],[-57.544,-1.611],[-57.353,-1.737],[-57.202,-1.713],[-57.079,-1.803],[-57.028,-1.918],[-56.859,-2.016],[-56.748,-2.011],[-56.721,-2.059],[-56.761,-2.174],[-56.613,-2.21],[-56.527,-2.139],[-56.414,-2.176],[-56.317,-2.14],[-56.228,-2.056],[-56.099,-2.027],[-56.204,-2.183],[-56.389,-2.278],[-56.464,-2.432],[-56.402,-2.456],[-56.61,-2.896],[-57,-3.759],[-57.586,-5.001],[-58.034,-5.978],[-58.267,-6.476],[-58.427,-6.616],[-58.482,-6.781],[-58.435,-6.909],[-58.335,-6.986],[-58.181,-7.182],[-58.137,-7.356],[-58.213,-7.458],[-58.202,-7.621],[-58.3,-7.777],[-58.378,-7.827],[-58.375,-7.899],[-58.293,-8.071],[-58.355,-8.393],[-58.418,-8.489],[-58.389,-8.595],[-58.437,-8.703],[-58.328,-8.712],[-58.442,-8.799],[-58.963,-8.803],[-60.42,-8.797],[-61.583,-8.799],[-61.626,-8.729],[-61.713,-8.688],[-61.775,-8.748],[-61.841,-8.739],[-61.86,-8.853],[-61.979,-8.873],[-62.031,-8.8],[-62.125,-8.802],[-62.184,-8.599],[-62.269,-8.576],[-62.336,-8.609],[-62.361,-8.398],[-62.465,-8.34],[-62.518,-8.385],[-62.585,-8.272],[-62.655,-8.229],[-62.692,-8.093],[-62.867,-7.976],[-63.621,-7.976],[-63.622,-8.03],[-63.723,-8.249],[-63.791,-8.333],[-63.871,-8.289],[-63.944,-8.331],[-63.973,-8.478],[-63.925,-8.575],[-63.986,-8.586],[-64.035,-8.694],[-64.125,-8.717],[-64.158,-8.843],[-64.142,-8.945],[-64.311,-8.996],[-64.323,-8.929],[-64.611,-9.016],[-64.808,-8.986],[-64.871,-9.012],[-64.94,-9.125],[-64.92,-9.22],[-65.053,-9.402],[-65.143,-9.447],[-65.212,-9.279],[-65.247,-9.258],[-65.377,-9.324],[-65.44,-9.312],[-65.432,-9.459],[-65.503,-9.47],[-65.588,-9.414],[-65.679,-9.451],[-65.73,-9.563],[-65.8,-9.585],[-65.825,-9.53],[-65.97,-9.413],[-66.26,-9.411],[-66.375,-9.426],[-66.392,-9.5],[-66.501,-9.634],[-66.775,-9.753],[-66.81,-9.818],[-67.325,-9.592],[-67.517,-9.561],[-68.647,-9.049],[-70.055,-7.845],[-72.585,-7.552],[-73.802,-7.112],[-73.727,-7.022],[-73.755,-6.942],[-73.643,-6.762],[-73.516,-6.677],[-73.381,-6.635],[-73.354,-6.595],[-73.213,-6.578],[-73.138,-6.498],[-73.109,-6.41],[-73.165,-6.274],[-73.25,-6.145],[-73.237,-6.032],[-72.992,-5.704],[-72.957,-5.465],[-72.87,-5.299],[-72.888,-5.166],[-72.738,-5.06],[-72.607,-5.028],[-72.597,-4.983],[-72.401,-4.884],[-72.366,-4.803],[-72.22,-4.771],[-71.955,-4.61],[-71.907,-4.515],[-71.809,-4.492],[-71.617,-4.529],[-71.476,-4.433],[-71.277,-4.441],[-71.214,-4.378],[-70.946,-4.377],[-70.759,-4.159],[-70.616,-4.193],[-70.522,-4.137],[-70.293,-4.16],[-70.308,-4.247],[-70.258,-4.31],[-70.153,-4.331],[-70.156,-4.275],[-70.076,-4.32],[-69.973,-4.324],[-69.933,-4.219],[-69.534,-2.001],[-69.43,-1.387],[-69.4,-1.371],[-69.431,-1.242],[-69.398,-1.144],[-69.464,-0.967],[-69.623,-0.732],[-69.574,-0.65],[-69.611,-0.513],[-69.718,-0.455],[-69.845,-0.346],[-69.923,-0.331],[-70.057,-0.187],[-70.042,-0.124],[-70.046,0.562],[-69.908,0.585],[-69.804,0.572],[-69.687,0.663],[-69.633,0.635],[-69.481,0.735],[-69.35,0.614],[-69.272,0.602],[-69.184,0.654],[-69.116,0.644],[-69.188,0.747],[-69.136,0.87],[-69.198,0.998],[-69.322,1.088],[-69.415,1.043],[-69.44,1.075],[-69.705,1.08],[-69.703,1.118],[-69.847,1.078],[-69.846,1.708],[-69.644,1.73],[-69.534,1.777],[-69.391,1.73],[-68.157,1.732],[-68.267,1.827],[-68.243,1.927],[-68.141,1.985],[-68.087,1.901],[-67.941,1.831],[-67.779,2.031],[-67.62,2.024],[-67.522,2.17],[-67.389,2.244],[-67.343,2.206],[-67.337,2.098],[-67.278,1.876],[-67.157,1.849],[-67.097,1.733],[-67.072,1.443],[-67.088,1.167],[-66.857,1.23],[-66.318,0.755],[-66.213,0.781],[-66.151,0.745],[-66.07,0.805],[-65.971,0.806],[-65.937,0.882],[-65.739,1],[-65.586,1.009],[-65.508,0.916],[-65.504,0.852],[-65.591,0.722],[-65.564,0.676],[-65.453,0.685],[-65.397,0.75],[-65.398,0.821],[-65.329,0.932],[-65.165,0.951],[-65.151,1.117],[-65.103,1.157],[-65.022,1.115],[-64.961,1.19],[-64.794,1.269],[-64.722,1.239],[-64.702,1.291],[-64.591,1.337],[-64.531,1.435],[-64.397,1.527],[-64.351,1.518],[-64.4,1.395],[-64.343,1.384],[-64.302,1.468],[-64.112,1.589],[-64.065,1.676],[-64.062,1.931],[-63.975,1.991],[-63.83,1.968],[-63.562,2.132],[-63.398,2.147],[-63.372,2.212],[-63.276,2.154],[-63.146,2.173],[-63.12,2.097],[-63.024,2.015],[-62.839,2.016],[-62.703,1.934],[-62.729,1.7],[-62.805,1.591],[-62.64,1.438],[-62.521,1.084],[-62.472,1.086],[-62.443,0.959],[-62.485,0.876],[-62.463,0.777],[-62.538,0.675],[-62.519,0.444],[-62.446,0.379],[-62.453,0.207],[-62.409,0.075],[-62.326,-0.003],[-62.32,-0.096],[-62.242,-0.17],[-62.253,-0.27],[-62.189,-0.322],[-62.28,-0.451],[-62.312,-0.576],[-62.287,-0.632],[-62.375,-0.708],[-62.487,-0.681],[-62.501,-0.781],[-62.376,-0.836],[-62.259,-0.976],[-62.172,-1.006],[-62.1,-1.097],[-62.017,-1.142],[-61.897,-1.395],[-61.798,-1.381],[-61.605,-1.448],[-61.475,-1.579],[-61.548,-1.429],[-61.619,-1.395],[-61.582,-1.353],[-61.627,-1.294],[-61.543,-1.062],[-61.581,-0.978],[-61.536,-0.754],[-61.458,-0.655],[-61.234,-0.562],[-61.223,-0.5],[-61.079,-0.506],[-61.057,-0.544],[-60.934,-0.548],[-60.928,-0.605],[-60.815,-0.69],[-60.762,-0.849],[-60.663,-0.881],[-60.536,-0.869],[-60.498,-0.781],[-60.347,-0.74],[-60.305,-0.666],[-60.4,-0.517],[-60.346,-0.438],[-60.305,-0.26],[-60.204,-0.03],[-60.14,0.027],[-60.134,0.104],[-60.059,0.163],[-60.038,0.264],[-58.895,0.264]]]}}, +{"type":"Feature","properties":{"uf":"RR","name":"Roraima"},"geometry":{"type":"Polygon","coordinates":[[[-63.372,2.212],[-63.372,2.426],[-63.52,2.422],[-63.604,2.457],[-63.75,2.442],[-63.834,2.492],[-63.934,2.457],[-64.048,2.518],[-63.986,2.649],[-64.015,2.812],[-64.07,2.918],[-64.214,3.091],[-64.115,3.335],[-64.184,3.489],[-64.196,3.579],[-64.289,3.7],[-64.479,3.783],[-64.58,3.928],[-64.674,4.005],[-64.732,4.12],[-64.811,4.175],[-64.78,4.287],[-64.685,4.239],[-64.624,4.135],[-64.561,4.102],[-64.432,4.135],[-64.172,4.129],[-64.109,4.085],[-64.036,3.934],[-63.969,3.879],[-63.85,3.95],[-63.7,3.944],[-63.661,4.01],[-63.581,3.886],[-63.511,3.847],[-63.412,3.912],[-63.393,3.98],[-63.205,3.949],[-63.234,3.882],[-63.204,3.812],[-63.123,3.805],[-63.073,3.686],[-62.952,3.612],[-62.838,3.736],[-62.749,3.673],[-62.731,3.71],[-62.787,3.929],[-62.736,4.04],[-62.533,4.047],[-62.542,4.109],[-62.438,4.183],[-62.148,4.079],[-61.993,4.175],[-61.819,4.168],[-61.75,4.255],[-61.562,4.251],[-61.508,4.322],[-61.521,4.384],[-61.448,4.439],[-61.352,4.419],[-61.267,4.538],[-61.126,4.506],[-60.998,4.516],[-60.934,4.585],[-60.948,4.656],[-60.721,4.777],[-60.584,4.956],[-60.645,5.059],[-60.697,5.229],[-60.546,5.194],[-60.328,5.206],[-60.212,5.272],[-60.133,5.225],[-60.096,5.141],[-59.972,5.075],[-60.036,4.791],[-60.026,4.706],[-60.123,4.605],[-60.161,4.518],[-59.972,4.509],[-59.736,4.424],[-59.676,4.347],[-59.732,4.287],[-59.731,4.181],[-59.623,4.129],[-59.654,4.069],[-59.516,3.946],[-59.565,3.916],[-59.596,3.796],[-59.665,3.782],[-59.668,3.704],[-59.873,3.564],[-59.805,3.51],[-59.841,3.425],[-59.806,3.354],[-59.912,3.196],[-59.908,3.113],[-59.956,3.081],[-59.984,2.929],[-59.988,2.68],[-59.898,2.482],[-59.899,2.363],[-59.74,2.293],[-59.726,2.02],[-59.746,1.852],[-59.658,1.823],[-59.69,1.756],[-59.534,1.716],[-59.373,1.506],[-59.34,1.518],[-59.253,1.388],[-59.049,1.323],[-58.918,1.317],[-58.895,1.228],[-58.895,0.264],[-60.038,0.264],[-60.059,0.163],[-60.134,0.104],[-60.14,0.027],[-60.204,-0.03],[-60.305,-0.26],[-60.346,-0.438],[-60.4,-0.517],[-60.305,-0.666],[-60.347,-0.74],[-60.498,-0.781],[-60.536,-0.869],[-60.663,-0.881],[-60.762,-0.849],[-60.815,-0.69],[-60.928,-0.605],[-60.934,-0.548],[-61.057,-0.544],[-61.079,-0.506],[-61.223,-0.5],[-61.234,-0.562],[-61.458,-0.655],[-61.536,-0.754],[-61.581,-0.978],[-61.543,-1.062],[-61.627,-1.294],[-61.582,-1.353],[-61.619,-1.395],[-61.548,-1.429],[-61.475,-1.579],[-61.605,-1.448],[-61.798,-1.381],[-61.897,-1.395],[-62.017,-1.142],[-62.1,-1.097],[-62.172,-1.006],[-62.259,-0.976],[-62.376,-0.836],[-62.501,-0.781],[-62.487,-0.681],[-62.375,-0.708],[-62.287,-0.632],[-62.312,-0.576],[-62.28,-0.451],[-62.189,-0.322],[-62.253,-0.27],[-62.242,-0.17],[-62.32,-0.096],[-62.326,-0.003],[-62.409,0.075],[-62.453,0.207],[-62.446,0.379],[-62.519,0.444],[-62.538,0.675],[-62.463,0.777],[-62.485,0.876],[-62.443,0.959],[-62.472,1.086],[-62.521,1.084],[-62.64,1.438],[-62.805,1.591],[-62.729,1.7],[-62.703,1.934],[-62.839,2.016],[-63.024,2.015],[-63.12,2.097],[-63.146,2.173],[-63.276,2.154],[-63.372,2.212]]]}}, +{"type":"Feature","properties":{"uf":"PA","name":"Pará"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-46.104,-1.202],[-46.162,-1.282],[-46.115,-1.352],[-46.176,-1.477],[-46.134,-1.582],[-46.155,-1.675],[-46.221,-1.731],[-46.295,-1.727],[-46.31,-1.806],[-46.211,-1.831],[-46.283,-2.154],[-46.433,-2.301],[-46.43,-2.537],[-46.507,-2.617],[-46.622,-2.645],[-46.67,-2.735],[-46.59,-2.846],[-46.673,-2.893],[-46.647,-2.97],[-46.689,-3.093],[-46.75,-3.124],[-46.815,-3.311],[-46.884,-3.337],[-46.956,-3.426],[-46.97,-3.512],[-47.038,-3.564],[-47.039,-3.673],[-47.085,-3.857],[-47.195,-3.986],[-47.319,-4.048],[-47.372,-4.245],[-47.442,-4.287],[-47.615,-4.56],[-47.68,-4.609],[-47.816,-4.615],[-48.755,-5.349],[-48.597,-5.422],[-48.393,-5.394],[-48.279,-5.54],[-48.138,-5.603],[-48.172,-5.709],[-48.276,-5.726],[-48.292,-5.837],[-48.233,-5.948],[-48.335,-6.004],[-48.288,-6.098],[-48.432,-6.177],[-48.383,-6.38],[-48.5,-6.351],[-48.607,-6.448],[-48.664,-6.568],[-48.652,-6.64],[-48.769,-6.731],[-49.019,-6.784],[-49.209,-6.925],[-49.185,-7.235],[-49.234,-7.329],[-49.378,-7.496],[-49.342,-7.656],[-49.161,-7.791],[-49.163,-7.921],[-49.221,-8.204],[-49.283,-8.38],[-49.349,-8.418],[-49.41,-8.58],[-49.592,-8.839],[-49.684,-8.856],[-49.843,-9.027],[-49.905,-9.17],[-50.038,-9.289],[-50.105,-9.475],[-50.09,-9.546],[-50.225,-9.841],[-51.304,-9.773],[-52.257,-9.708],[-54.613,-9.536],[-56.672,-9.367],[-56.754,-9.406],[-56.82,-9.246],[-56.995,-9.234],[-57.06,-9.182],[-57.039,-9.098],[-57.204,-8.921],[-57.289,-8.916],[-57.416,-8.859],[-57.417,-8.793],[-57.593,-8.756],[-57.633,-8.527],[-57.687,-8.407],[-57.642,-8.22],[-57.832,-7.961],[-57.835,-7.881],[-57.897,-7.678],[-58.061,-7.395],[-58.137,-7.356],[-58.181,-7.182],[-58.335,-6.986],[-58.435,-6.909],[-58.482,-6.781],[-58.427,-6.616],[-58.267,-6.476],[-58.034,-5.978],[-57.586,-5.001],[-57,-3.759],[-56.61,-2.896],[-56.402,-2.456],[-56.464,-2.432],[-56.389,-2.278],[-56.204,-2.183],[-56.099,-2.027],[-56.228,-2.056],[-56.317,-2.14],[-56.414,-2.176],[-56.527,-2.139],[-56.613,-2.21],[-56.761,-2.174],[-56.721,-2.059],[-56.748,-2.011],[-56.859,-2.016],[-57.028,-1.918],[-57.079,-1.803],[-57.202,-1.713],[-57.353,-1.737],[-57.544,-1.611],[-57.689,-1.587],[-57.718,-1.503],[-57.825,-1.438],[-57.96,-1.397],[-57.999,-1.331],[-57.969,-1.163],[-58.059,-1.106],[-58.163,-1.23],[-58.258,-1.131],[-58.317,-1.148],[-58.428,-1.025],[-58.434,-0.892],[-58.706,-0.68],[-58.725,-0.438],[-58.869,-0.347],[-58.864,-0.078],[-58.895,-0.011],[-58.895,0.264],[-58.895,1.228],[-58.821,1.171],[-58.716,1.241],[-58.71,1.29],[-58.496,1.268],[-58.472,1.374],[-58.505,1.465],[-58.386,1.47],[-58.332,1.591],[-58.233,1.548],[-58.161,1.56],[-58.129,1.499],[-58.006,1.517],[-57.981,1.659],[-57.927,1.645],[-57.792,1.727],[-57.65,1.682],[-57.534,1.708],[-57.451,1.806],[-57.433,1.906],[-57.346,1.98],[-57.253,1.949],[-57.087,2.027],[-57.001,1.907],[-56.92,1.93],[-56.801,1.874],[-56.621,1.946],[-56.58,1.906],[-56.437,1.952],[-56.335,1.937],[-56.241,1.88],[-56.153,1.891],[-55.999,1.831],[-55.904,1.888],[-55.935,1.997],[-55.902,2.029],[-56.043,2.228],[-56.132,2.264],[-56.09,2.372],[-56.051,2.335],[-55.99,2.417],[-55.978,2.527],[-55.935,2.534],[-55.747,2.411],[-55.5,2.443],[-55.385,2.419],[-55.32,2.516],[-55.235,2.504],[-55.123,2.568],[-55.103,2.526],[-54.954,2.584],[-54.872,2.434],[-54.796,2.318],[-54.763,2.202],[-54.806,2.139],[-54.804,2.026],[-54.745,1.776],[-54.602,1.785],[-54.496,1.747],[-54.352,1.754],[-54.201,1.646],[-54.144,1.641],[-54.069,1.501],[-53.896,1.455],[-53.888,1.408],[-53.538,1.341],[-53.525,1.212],[-53.426,1.226],[-53.402,1.154],[-53.459,1.134],[-53.411,0.929],[-53.19,0.768],[-53.106,0.68],[-53.147,0.6],[-53.175,0.382],[-53.094,0.259],[-53.094,0.195],[-52.983,0.033],[-52.933,-0.142],[-52.836,-0.186],[-52.684,-0.314],[-52.695,-0.496],[-52.64,-0.585],[-52.535,-0.574],[-52.497,-0.711],[-52.511,-0.857],[-52.412,-0.863],[-52.417,-1.026],[-52.333,-1.116],[-52.12,-1.146],[-52.07,-1.236],[-52.055,-1.177],[-51.778,-1.14],[-51.7,-1.063],[-51.683,-0.795],[-51.595,-0.65],[-51.458,-0.513],[-51.362,-0.368],[-51.348,-0.3],[-51.208,-0.117],[-51.069,-0.079],[-50.975,0.042],[-50.785,0.112],[-50.668,0.185],[-50.553,0.313],[-50.436,0.6],[-50.329,0.674],[-50.226,0.696],[-50.133,0.62],[-50.073,0.636],[-50.036,0.534],[-50.077,0.463],[-50.173,0.398],[-50.168,0.34],[-49.889,0.323],[-49.582,0.419],[-49.489,0.334],[-49.633,0.241],[-49.7,0.151],[-49.632,0.075],[-49.583,0.09],[-49.397,0.062],[-49.359,-0.026],[-49.432,-0.088],[-49.422,-0.168],[-49.196,-0.131],[-49.137,-0.168],[-49.017,-0.16],[-48.918,-0.23],[-48.81,-0.215],[-48.744,-0.25],[-48.429,-0.227],[-48.375,-0.303],[-48.441,-0.412],[-48.506,-0.81],[-48.475,-0.874],[-48.366,-0.919],[-48.268,-0.903],[-48.172,-0.816],[-48.051,-0.659],[-47.977,-0.686],[-47.909,-0.577],[-47.831,-0.61],[-47.704,-0.535],[-47.633,-0.602],[-47.631,-0.706],[-47.589,-0.576],[-47.531,-0.616],[-47.501,-0.709],[-47.474,-0.592],[-47.316,-0.592],[-47.171,-0.676],[-47.16,-0.762],[-47.121,-0.676],[-47.064,-0.795],[-46.975,-0.704],[-46.943,-0.859],[-46.836,-0.747],[-46.796,-0.87],[-46.665,-0.791],[-46.596,-0.842],[-46.636,-0.969],[-46.576,-0.981],[-46.496,-0.872],[-46.41,-0.888],[-46.48,-0.924],[-46.469,-1.025],[-46.288,-0.999],[-46.265,-0.913],[-46.201,-0.887],[-46.184,-1.053],[-46.095,-1.021],[-46.063,-1.106],[-46.104,-1.202]]],[[[-49.626,0.19],[-49.626,0.19],[-49.626,0.19],[-49.626,0.19]]],[[[-49.45,0.117],[-49.45,0.117],[-49.45,0.117],[-49.45,0.117]]],[[[-49.618,0.199],[-49.618,0.199],[-49.618,0.199],[-49.618,0.199]]],[[[-48.876,-0.182],[-48.876,-0.182],[-48.876,-0.182],[-48.876,-0.182]]],[[[-46.311,-1.006],[-46.311,-1.006],[-46.311,-1.006],[-46.311,-1.006]]]]}}, +{"type":"Feature","properties":{"uf":"AP","name":"Amapá"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-54.872,2.434],[-54.744,2.472],[-54.685,2.447],[-54.692,2.361],[-54.602,2.337],[-54.437,2.21],[-54.189,2.179],[-54.001,2.23],[-53.742,2.375],[-53.749,2.313],[-53.473,2.257],[-53.323,2.347],[-53.229,2.262],[-53.279,2.186],[-53.084,2.213],[-52.988,2.169],[-52.904,2.189],[-52.873,2.258],[-52.661,2.374],[-52.527,2.582],[-52.478,2.782],[-52.328,3.071],[-52.351,3.123],[-52.292,3.226],[-52.234,3.241],[-52.087,3.49],[-51.989,3.627],[-51.973,3.706],[-51.799,3.887],[-51.782,3.964],[-51.656,4.054],[-51.623,4.226],[-51.677,4.333],[-51.638,4.509],[-51.486,4.437],[-51.283,4.25],[-51.216,4.15],[-51.176,4.011],[-51.179,3.92],[-51.081,3.885],[-51.082,3.56],[-51.109,3.451],[-51.033,3.214],[-51.044,3.134],[-50.954,2.837],[-50.887,2.746],[-50.816,2.503],[-50.768,2.407],[-50.684,2.144],[-50.598,2.064],[-50.583,1.99],[-50.424,1.805],[-50.159,1.811],[-49.921,1.688],[-49.882,1.5],[-49.904,1.296],[-49.883,1.169],[-49.89,1.014],[-49.989,0.94],[-49.988,0.875],[-50.097,0.767],[-50.22,0.743],[-50.226,0.696],[-50.329,0.674],[-50.436,0.6],[-50.553,0.313],[-50.668,0.185],[-50.785,0.112],[-50.975,0.042],[-51.069,-0.079],[-51.208,-0.117],[-51.348,-0.3],[-51.362,-0.368],[-51.458,-0.513],[-51.595,-0.65],[-51.683,-0.795],[-51.7,-1.063],[-51.778,-1.14],[-52.055,-1.177],[-52.07,-1.236],[-52.12,-1.146],[-52.333,-1.116],[-52.417,-1.026],[-52.412,-0.863],[-52.511,-0.857],[-52.497,-0.711],[-52.535,-0.574],[-52.64,-0.585],[-52.695,-0.496],[-52.684,-0.314],[-52.836,-0.186],[-52.933,-0.142],[-52.983,0.033],[-53.094,0.195],[-53.094,0.259],[-53.175,0.382],[-53.147,0.6],[-53.106,0.68],[-53.19,0.768],[-53.411,0.929],[-53.459,1.134],[-53.402,1.154],[-53.426,1.226],[-53.525,1.212],[-53.538,1.341],[-53.888,1.408],[-53.896,1.455],[-54.069,1.501],[-54.144,1.641],[-54.201,1.646],[-54.352,1.754],[-54.496,1.747],[-54.602,1.785],[-54.745,1.776],[-54.804,2.026],[-54.806,2.139],[-54.763,2.202],[-54.796,2.318],[-54.872,2.434]]],[[[-50.509,2.185],[-50.423,2.119],[-50.508,2.1],[-50.509,2.185]]],[[[-50.45,2.109],[-50.405,2.111],[-50.317,1.948],[-50.367,1.89],[-50.455,1.889],[-50.521,2.005],[-50.504,2.094],[-50.45,2.109]]]]}}, +{"type":"Feature","properties":{"uf":"TO","name":"Tocantins"},"geometry":{"type":"Polygon","coordinates":[[[-48.755,-5.349],[-48.658,-5.308],[-48.606,-5.336],[-48.519,-5.192],[-48.364,-5.168],[-48.185,-5.257],[-48.069,-5.271],[-48.015,-5.238],[-47.886,-5.261],[-47.827,-5.387],[-47.744,-5.38],[-47.619,-5.46],[-47.56,-5.463],[-47.483,-5.565],[-47.493,-5.71],[-47.442,-5.831],[-47.448,-5.99],[-47.423,-6.16],[-47.379,-6.273],[-47.43,-6.423],[-47.416,-6.484],[-47.499,-6.671],[-47.529,-6.828],[-47.508,-6.907],[-47.547,-7.017],[-47.66,-7.151],[-47.745,-7.162],[-47.737,-7.217],[-47.651,-7.302],[-47.543,-7.265],[-47.478,-7.337],[-47.58,-7.419],[-47.358,-7.616],[-47.317,-7.704],[-47.181,-7.859],[-47.046,-8.053],[-46.963,-8.039],[-46.883,-7.964],[-46.605,-7.897],[-46.501,-7.973],[-46.466,-8.073],[-46.544,-8.317],[-46.633,-8.318],[-46.727,-8.383],[-46.777,-8.368],[-46.8,-8.454],[-46.902,-8.586],[-46.887,-8.686],[-46.926,-8.735],[-46.918,-8.857],[-46.993,-8.879],[-47.058,-9.053],[-46.94,-9.064],[-46.82,-9.212],[-46.819,-9.303],[-46.759,-9.381],[-46.666,-9.392],[-46.573,-9.478],[-46.539,-9.56],[-46.583,-9.586],[-46.644,-9.74],[-46.513,-9.797],[-46.473,-9.877],[-46.445,-10.077],[-46.325,-10.183],[-46.211,-10.17],[-46.163,-10.214],[-46.028,-10.177],[-46.012,-10.251],[-45.971,-10.359],[-45.911,-10.371],[-45.897,-10.264],[-45.744,-10.24],[-45.723,-10.155],[-45.699,-10.259],[-45.798,-10.323],[-45.82,-10.456],[-46.061,-10.6],[-46.128,-10.621],[-46.206,-10.798],[-46.26,-10.812],[-46.267,-10.935],[-46.342,-10.939],[-46.553,-11.265],[-46.576,-11.342],[-46.524,-11.464],[-46.439,-11.521],[-46.35,-11.506],[-46.209,-11.585],[-46.081,-11.624],[-46.155,-11.65],[-46.244,-11.605],[-46.341,-11.61],[-46.308,-11.687],[-46.374,-11.768],[-46.38,-11.867],[-46.248,-11.919],[-46.422,-12.029],[-46.371,-12.1],[-46.388,-12.249],[-46.37,-12.347],[-46.3,-12.364],[-46.266,-12.499],[-46.307,-12.637],[-46.268,-12.716],[-46.308,-12.942],[-46.113,-12.918],[-46.198,-12.957],[-46.371,-12.988],[-46.366,-12.863],[-46.418,-12.822],[-46.455,-12.971],[-46.751,-12.969],[-47.011,-13.141],[-47.225,-13.193],[-47.281,-13.265],[-47.38,-13.231],[-47.417,-13.271],[-47.565,-13.18],[-47.569,-13.117],[-47.646,-13.12],[-47.667,-13.208],[-47.624,-13.376],[-47.68,-13.468],[-47.798,-13.329],[-48.033,-13.274],[-48.156,-13.308],[-48.166,-13.189],[-48.235,-13.176],[-48.385,-13.264],[-48.462,-13.275],[-48.516,-13.139],[-48.554,-13.303],[-48.6,-13.288],[-48.576,-13.124],[-48.736,-12.921],[-48.87,-12.803],[-48.976,-12.957],[-49.118,-12.79],[-49.238,-12.884],[-49.355,-13.107],[-49.339,-13.253],[-49.368,-13.272],[-49.909,-12.975],[-50.286,-12.84],[-50.296,-12.711],[-50.193,-12.564],[-50.206,-12.445],[-50.305,-12.492],[-50.479,-12.713],[-50.511,-12.861],[-50.625,-12.8],[-50.623,-12.735],[-50.7,-12.595],[-50.624,-12.455],[-50.681,-12.217],[-50.682,-11.991],[-50.639,-11.885],[-50.686,-11.859],[-50.722,-11.74],[-50.664,-11.677],[-50.657,-11.593],[-50.739,-11.544],[-50.742,-11.46],[-50.671,-11.287],[-50.664,-11.152],[-50.609,-11.067],[-50.633,-10.932],[-50.571,-10.752],[-50.603,-10.66],[-50.483,-10.49],[-50.474,-10.405],[-50.418,-10.355],[-50.382,-10.212],[-50.398,-10.155],[-50.303,-10.035],[-50.225,-9.841],[-50.09,-9.546],[-50.105,-9.475],[-50.038,-9.289],[-49.905,-9.17],[-49.843,-9.027],[-49.684,-8.856],[-49.592,-8.839],[-49.41,-8.58],[-49.349,-8.418],[-49.283,-8.38],[-49.221,-8.204],[-49.163,-7.921],[-49.161,-7.791],[-49.342,-7.656],[-49.378,-7.496],[-49.234,-7.329],[-49.185,-7.235],[-49.209,-6.925],[-49.019,-6.784],[-48.769,-6.731],[-48.652,-6.64],[-48.664,-6.568],[-48.607,-6.448],[-48.5,-6.351],[-48.383,-6.38],[-48.432,-6.177],[-48.288,-6.098],[-48.335,-6.004],[-48.233,-5.948],[-48.292,-5.837],[-48.276,-5.726],[-48.172,-5.709],[-48.138,-5.603],[-48.279,-5.54],[-48.393,-5.394],[-48.597,-5.422],[-48.755,-5.349]]]}}, +{"type":"Feature","properties":{"uf":"MA","name":"Maranhão"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-48.755,-5.349],[-47.816,-4.615],[-47.68,-4.609],[-47.615,-4.56],[-47.442,-4.287],[-47.372,-4.245],[-47.319,-4.048],[-47.195,-3.986],[-47.085,-3.857],[-47.039,-3.673],[-47.038,-3.564],[-46.97,-3.512],[-46.956,-3.426],[-46.884,-3.337],[-46.815,-3.311],[-46.75,-3.124],[-46.689,-3.093],[-46.647,-2.97],[-46.673,-2.893],[-46.59,-2.846],[-46.67,-2.735],[-46.622,-2.645],[-46.507,-2.617],[-46.43,-2.537],[-46.433,-2.301],[-46.283,-2.154],[-46.211,-1.831],[-46.31,-1.806],[-46.295,-1.727],[-46.221,-1.731],[-46.155,-1.675],[-46.134,-1.582],[-46.176,-1.477],[-46.115,-1.352],[-46.162,-1.282],[-46.104,-1.202],[-45.976,-1.066],[-45.958,-1.195],[-45.871,-1.052],[-45.883,-1.149],[-45.847,-1.213],[-45.736,-1.207],[-45.69,-1.274],[-45.476,-1.325],[-45.489,-1.469],[-45.372,-1.397],[-45.338,-1.315],[-45.299,-1.433],[-45.343,-1.587],[-45.268,-1.599],[-45.196,-1.488],[-45.103,-1.473],[-44.974,-1.5],[-44.885,-1.419],[-44.83,-1.428],[-44.916,-1.547],[-44.854,-1.617],[-44.705,-1.554],[-44.636,-1.617],[-44.777,-1.68],[-44.7,-1.731],[-44.752,-1.789],[-44.647,-1.759],[-44.652,-1.715],[-44.513,-1.889],[-44.482,-1.988],[-44.566,-2.022],[-44.498,-2.049],[-44.589,-2.137],[-44.644,-2.296],[-44.53,-2.16],[-44.462,-2.146],[-44.398,-2.215],[-44.358,-2.337],[-44.388,-2.409],[-44.537,-2.545],[-44.627,-2.703],[-44.633,-2.846],[-44.68,-2.919],[-44.662,-3.013],[-44.548,-3.045],[-44.425,-2.944],[-44.41,-2.799],[-44.354,-2.603],[-44.378,-2.566],[-44.305,-2.487],[-44.095,-2.407],[-44.032,-2.406],[-44.034,-2.55],[-44.08,-2.564],[-44.138,-2.682],[-44.251,-2.732],[-44.257,-2.778],[-44.079,-2.744],[-43.931,-2.562],[-43.85,-2.51],[-43.838,-2.417],[-43.727,-2.386],[-43.72,-2.291],[-43.627,-2.251],[-43.645,-2.401],[-43.741,-2.435],[-43.748,-2.494],[-43.624,-2.51],[-43.537,-2.422],[-43.462,-2.486],[-43.483,-2.385],[-43.405,-2.335],[-43.237,-2.359],[-42.829,-2.543],[-42.696,-2.574],[-42.587,-2.669],[-42.365,-2.715],[-42.264,-2.758],[-42.255,-2.676],[-42.071,-2.687],[-42.043,-2.73],[-41.807,-2.73],[-41.826,-2.757],[-41.866,-2.87],[-41.797,-2.961],[-42.013,-3.248],[-42.124,-3.267],[-42.151,-3.387],[-42.302,-3.452],[-42.334,-3.434],[-42.47,-3.484],[-42.496,-3.446],[-42.563,-3.559],[-42.675,-3.673],[-42.684,-3.803],[-42.739,-3.927],[-42.845,-4.037],[-42.89,-4.155],[-42.986,-4.22],[-42.949,-4.385],[-42.897,-4.402],[-42.85,-4.498],[-42.953,-4.687],[-42.95,-4.769],[-42.897,-4.843],[-42.799,-5.19],[-42.825,-5.346],[-42.911,-5.396],[-43.033,-5.592],[-43.095,-5.615],[-43.049,-6.102],[-42.854,-6.25],[-42.829,-6.341],[-42.877,-6.417],[-42.887,-6.578],[-42.993,-6.749],[-43.148,-6.784],[-43.248,-6.768],[-43.455,-6.846],[-43.637,-6.72],[-43.791,-6.702],[-43.93,-6.771],[-43.957,-6.743],[-44.116,-6.806],[-44.313,-7.119],[-44.386,-7.121],[-44.565,-7.229],[-44.69,-7.396],[-44.817,-7.361],[-44.923,-7.467],[-45.05,-7.513],[-45.155,-7.513],[-45.215,-7.567],[-45.335,-7.578],[-45.456,-7.67],[-45.577,-8.03],[-45.584,-8.157],[-45.664,-8.25],[-45.739,-8.501],[-45.841,-8.716],[-45.96,-8.815],[-45.995,-8.927],[-45.936,-9.038],[-45.898,-9.185],[-45.908,-9.298],[-45.799,-9.417],[-45.842,-9.563],[-45.821,-9.773],[-45.864,-9.861],[-45.842,-9.939],[-45.878,-10.11],[-45.974,-10.248],[-46.012,-10.251],[-46.028,-10.177],[-46.163,-10.214],[-46.211,-10.17],[-46.325,-10.183],[-46.445,-10.077],[-46.473,-9.877],[-46.513,-9.797],[-46.644,-9.74],[-46.583,-9.586],[-46.539,-9.56],[-46.573,-9.478],[-46.666,-9.392],[-46.759,-9.381],[-46.819,-9.303],[-46.82,-9.212],[-46.94,-9.064],[-47.058,-9.053],[-46.993,-8.879],[-46.918,-8.857],[-46.926,-8.735],[-46.887,-8.686],[-46.902,-8.586],[-46.8,-8.454],[-46.777,-8.368],[-46.727,-8.383],[-46.633,-8.318],[-46.544,-8.317],[-46.466,-8.073],[-46.501,-7.973],[-46.605,-7.897],[-46.883,-7.964],[-46.963,-8.039],[-47.046,-8.053],[-47.181,-7.859],[-47.317,-7.704],[-47.358,-7.616],[-47.58,-7.419],[-47.478,-7.337],[-47.543,-7.265],[-47.651,-7.302],[-47.737,-7.217],[-47.745,-7.162],[-47.66,-7.151],[-47.547,-7.017],[-47.508,-6.907],[-47.529,-6.828],[-47.499,-6.671],[-47.416,-6.484],[-47.43,-6.423],[-47.379,-6.273],[-47.423,-6.16],[-47.448,-5.99],[-47.442,-5.831],[-47.493,-5.71],[-47.483,-5.565],[-47.56,-5.463],[-47.619,-5.46],[-47.744,-5.38],[-47.827,-5.387],[-47.886,-5.261],[-48.015,-5.238],[-48.069,-5.271],[-48.185,-5.257],[-48.364,-5.168],[-48.519,-5.192],[-48.606,-5.336],[-48.658,-5.308],[-48.755,-5.349]]],[[[-44.481,-2.727],[-44.479,-2.958],[-44.561,-3.022],[-44.631,-2.988],[-44.586,-2.815],[-44.481,-2.727]]],[[[-45.536,-1.228],[-45.536,-1.228],[-45.536,-1.228],[-45.536,-1.228]]],[[[-44.966,-1.271],[-44.867,-1.329],[-45.005,-1.401],[-45.029,-1.318],[-44.966,-1.271]]],[[[-44.752,-1.468],[-44.752,-1.468],[-44.752,-1.468],[-44.752,-1.468]]],[[[-45.691,-1.265],[-45.691,-1.265],[-45.691,-1.265],[-45.691,-1.265]]],[[[-43.542,-2.361],[-43.562,-2.424],[-43.613,-2.378],[-43.542,-2.361]]]]}}, +{"type":"Feature","properties":{"uf":"PI","name":"Piauí"},"geometry":{"type":"Polygon","coordinates":[[[-40.524,-7.318],[-40.548,-7.393],[-40.715,-7.481],[-40.622,-7.658],[-40.674,-7.762],[-40.555,-7.818],[-40.544,-7.962],[-40.604,-8.104],[-40.711,-8.225],[-40.784,-8.262],[-40.819,-8.361],[-40.978,-8.399],[-41.213,-8.644],[-41.358,-8.708],[-41.488,-8.844],[-41.546,-8.962],[-41.606,-8.957],[-41.731,-9.028],[-41.838,-9.242],[-41.922,-9.279],[-41.972,-9.249],[-42.316,-9.317],[-42.493,-9.495],[-42.59,-9.498],[-42.716,-9.554],[-42.77,-9.619],[-42.828,-9.56],[-42.944,-9.521],[-42.959,-9.428],[-43.122,-9.373],[-43.194,-9.421],[-43.285,-9.42],[-43.438,-9.266],[-43.579,-9.32],[-43.689,-9.42],[-43.782,-9.453],[-43.853,-9.562],[-43.785,-9.762],[-43.652,-9.844],[-43.709,-9.913],[-43.665,-9.991],[-43.699,-10.078],[-43.759,-10.075],[-43.837,-10.302],[-43.937,-10.436],[-44.028,-10.412],[-44.072,-10.58],[-44.139,-10.64],[-44.267,-10.623],[-44.344,-10.55],[-44.5,-10.65],[-44.578,-10.627],[-44.669,-10.687],[-44.667,-10.759],[-44.788,-10.808],[-44.808,-10.866],[-44.931,-10.929],[-45.082,-10.839],[-45.246,-10.822],[-45.358,-10.732],[-45.437,-10.616],[-45.447,-10.509],[-45.396,-10.445],[-45.43,-10.359],[-45.522,-10.277],[-45.603,-10.108],[-45.723,-10.155],[-45.744,-10.24],[-45.897,-10.264],[-45.911,-10.371],[-45.971,-10.359],[-46.012,-10.251],[-45.974,-10.248],[-45.878,-10.11],[-45.842,-9.939],[-45.864,-9.861],[-45.821,-9.773],[-45.842,-9.563],[-45.799,-9.417],[-45.908,-9.298],[-45.898,-9.185],[-45.936,-9.038],[-45.995,-8.927],[-45.96,-8.815],[-45.841,-8.716],[-45.739,-8.501],[-45.664,-8.25],[-45.584,-8.157],[-45.577,-8.03],[-45.456,-7.67],[-45.335,-7.578],[-45.215,-7.567],[-45.155,-7.513],[-45.05,-7.513],[-44.923,-7.467],[-44.817,-7.361],[-44.69,-7.396],[-44.565,-7.229],[-44.386,-7.121],[-44.313,-7.119],[-44.116,-6.806],[-43.957,-6.743],[-43.93,-6.771],[-43.791,-6.702],[-43.637,-6.72],[-43.455,-6.846],[-43.248,-6.768],[-43.148,-6.784],[-42.993,-6.749],[-42.887,-6.578],[-42.877,-6.417],[-42.829,-6.341],[-42.854,-6.25],[-43.049,-6.102],[-43.095,-5.615],[-43.033,-5.592],[-42.911,-5.396],[-42.825,-5.346],[-42.799,-5.19],[-42.897,-4.843],[-42.95,-4.769],[-42.953,-4.687],[-42.85,-4.498],[-42.897,-4.402],[-42.949,-4.385],[-42.986,-4.22],[-42.89,-4.155],[-42.845,-4.037],[-42.739,-3.927],[-42.684,-3.803],[-42.675,-3.673],[-42.563,-3.559],[-42.496,-3.446],[-42.47,-3.484],[-42.334,-3.434],[-42.302,-3.452],[-42.151,-3.387],[-42.124,-3.267],[-42.013,-3.248],[-41.797,-2.961],[-41.866,-2.87],[-41.826,-2.757],[-41.612,-2.896],[-41.323,-2.921],[-41.271,-2.971],[-41.256,-3.088],[-41.414,-3.35],[-41.324,-3.386],[-41.303,-3.492],[-41.367,-3.562],[-41.334,-3.684],[-41.239,-3.713],[-41.288,-3.822],[-41.22,-3.941],[-41.239,-4.017],[-41.113,-4.05],[-41.139,-4.121],[-41.098,-4.18],[-41.136,-4.233],[-41.149,-4.367],[-41.233,-4.61],[-41.181,-4.673],[-41.25,-4.757],[-41.213,-4.772],[-41.25,-4.87],[-41.082,-5.088],[-40.929,-5.172],[-40.911,-5.407],[-40.932,-5.549],[-40.902,-5.608],[-40.939,-5.673],[-40.874,-5.973],[-40.906,-6.046],[-40.858,-6.105],[-40.852,-6.224],[-40.787,-6.305],[-40.788,-6.524],[-40.71,-6.676],[-40.477,-6.733],[-40.432,-6.813],[-40.428,-7.034],[-40.49,-7.119],[-40.524,-7.318]]]}}, +{"type":"Feature","properties":{"uf":"CE","name":"Ceará"},"geometry":{"type":"Polygon","coordinates":[[[-41.323,-2.921],[-41.271,-2.887],[-41.102,-2.901],[-40.76,-2.849],[-40.59,-2.846],[-40.498,-2.784],[-40.455,-2.803],[-40.188,-2.812],[-39.966,-2.851],[-39.721,-2.997],[-39.62,-3.026],[-39.379,-3.183],[-39.257,-3.219],[-39.065,-3.407],[-38.974,-3.411],[-38.918,-3.494],[-38.807,-3.548],[-38.652,-3.682],[-38.463,-3.713],[-38.36,-3.885],[-38.273,-3.945],[-38.115,-4.149],[-37.907,-4.34],[-37.77,-4.401],[-37.732,-4.488],[-37.603,-4.617],[-37.495,-4.629],[-37.295,-4.73],[-37.253,-4.831],[-37.64,-4.926],[-37.677,-5.052],[-37.725,-5.071],[-37.788,-5.219],[-37.787,-5.297],[-37.847,-5.34],[-37.902,-5.501],[-37.99,-5.556],[-38.083,-5.672],[-38.047,-5.733],[-38.163,-5.939],[-38.306,-6.087],[-38.433,-6.078],[-38.582,-6.319],[-38.562,-6.356],[-38.538,-6.421],[-38.612,-6.513],[-38.672,-6.706],[-38.614,-6.782],[-38.675,-6.862],[-38.765,-6.911],[-38.765,-6.994],[-38.656,-7.092],[-38.644,-7.184],[-38.535,-7.29],[-38.653,-7.553],[-38.715,-7.622],[-38.94,-7.759],[-38.962,-7.844],[-39.088,-7.833],[-39.147,-7.718],[-39.253,-7.705],[-39.473,-7.573],[-39.461,-7.471],[-39.546,-7.484],[-39.572,-7.43],[-39.741,-7.327],[-39.858,-7.325],[-40.001,-7.374],[-40.128,-7.382],[-40.263,-7.301],[-40.361,-7.356],[-40.449,-7.361],[-40.524,-7.318],[-40.49,-7.119],[-40.428,-7.034],[-40.432,-6.813],[-40.477,-6.733],[-40.71,-6.676],[-40.788,-6.524],[-40.787,-6.305],[-40.852,-6.224],[-40.858,-6.105],[-40.906,-6.046],[-40.874,-5.973],[-40.939,-5.673],[-40.902,-5.608],[-40.932,-5.549],[-40.911,-5.407],[-40.929,-5.172],[-41.082,-5.088],[-41.25,-4.87],[-41.213,-4.772],[-41.25,-4.757],[-41.181,-4.673],[-41.233,-4.61],[-41.149,-4.367],[-41.136,-4.233],[-41.098,-4.18],[-41.139,-4.121],[-41.113,-4.05],[-41.239,-4.017],[-41.22,-3.941],[-41.288,-3.822],[-41.239,-3.713],[-41.334,-3.684],[-41.367,-3.562],[-41.303,-3.492],[-41.324,-3.386],[-41.414,-3.35],[-41.256,-3.088],[-41.271,-2.971],[-41.323,-2.921]]]}}, +{"type":"Feature","properties":{"uf":"RN","name":"Rio Grande do Norte"},"geometry":{"type":"Polygon","coordinates":[[[-34.968,-6.487],[-35.146,-6.553],[-35.224,-6.522],[-35.341,-6.539],[-35.408,-6.49],[-35.565,-6.492],[-35.664,-6.447],[-35.773,-6.483],[-36.005,-6.481],[-36.077,-6.406],[-36.226,-6.434],[-36.292,-6.396],[-36.295,-6.292],[-36.395,-6.294],[-36.433,-6.363],[-36.5,-6.358],[-36.526,-6.501],[-36.442,-6.565],[-36.438,-6.629],[-36.514,-6.594],[-36.559,-6.708],[-36.505,-6.754],[-36.604,-6.93],[-36.766,-6.936],[-36.73,-6.836],[-36.835,-6.731],[-36.959,-6.783],[-37.004,-6.71],[-37.193,-6.826],[-37.255,-6.811],[-37.314,-6.689],[-37.485,-6.71],[-37.465,-6.533],[-37.396,-6.51],[-37.363,-6.322],[-37.273,-6.28],[-37.157,-6.152],[-37.174,-6.048],[-37.256,-6.028],[-37.446,-6.112],[-37.459,-6.145],[-37.742,-6.189],[-37.859,-6.374],[-37.942,-6.424],[-38.145,-6.494],[-38.289,-6.506],[-38.402,-6.41],[-38.562,-6.356],[-38.582,-6.319],[-38.433,-6.078],[-38.306,-6.087],[-38.163,-5.939],[-38.047,-5.733],[-38.083,-5.672],[-37.99,-5.556],[-37.902,-5.501],[-37.847,-5.34],[-37.787,-5.297],[-37.788,-5.219],[-37.725,-5.071],[-37.677,-5.052],[-37.64,-4.926],[-37.253,-4.831],[-37.158,-4.945],[-37.037,-4.95],[-36.96,-4.919],[-36.878,-4.951],[-36.716,-5.081],[-36.542,-5.088],[-36.508,-5.062],[-36.16,-5.096],[-35.981,-5.042],[-35.791,-5.077],[-35.489,-5.157],[-35.371,-5.285],[-35.355,-5.355],[-35.261,-5.481],[-35.153,-5.947],[-35.096,-6.057],[-35.094,-6.183],[-35.036,-6.235],[-35.02,-6.366],[-34.968,-6.487]]]}}, +{"type":"Feature","properties":{"uf":"PB","name":"Paraíba"},"geometry":{"type":"Polygon","coordinates":[[[-34.834,-7.549],[-34.961,-7.539],[-34.985,-7.462],[-35.07,-7.399],[-35.249,-7.375],[-35.495,-7.484],[-35.527,-7.645],[-36.003,-7.813],[-36.217,-7.764],[-36.43,-7.827],[-36.48,-7.925],[-36.572,-7.904],[-36.612,-7.949],[-36.629,-8.111],[-36.687,-8.156],[-36.862,-8.222],[-36.991,-8.303],[-37.082,-8.262],[-37.16,-8.17],[-37.16,-7.997],[-37.34,-7.98],[-37.139,-7.745],[-37.198,-7.635],[-37.118,-7.534],[-36.985,-7.476],[-37.014,-7.404],[-37.168,-7.332],[-37.234,-7.274],[-37.328,-7.288],[-37.386,-7.357],[-37.496,-7.365],[-37.528,-7.465],[-37.656,-7.523],[-37.746,-7.609],[-37.857,-7.652],[-37.958,-7.766],[-38.04,-7.748],[-38.077,-7.831],[-38.164,-7.78],[-38.275,-7.834],[-38.327,-7.694],[-38.409,-7.73],[-38.594,-7.754],[-38.715,-7.622],[-38.653,-7.553],[-38.535,-7.29],[-38.644,-7.184],[-38.656,-7.092],[-38.765,-6.994],[-38.765,-6.911],[-38.675,-6.862],[-38.614,-6.782],[-38.672,-6.706],[-38.612,-6.513],[-38.538,-6.421],[-38.562,-6.356],[-38.402,-6.41],[-38.289,-6.506],[-38.145,-6.494],[-37.942,-6.424],[-37.859,-6.374],[-37.742,-6.189],[-37.459,-6.145],[-37.446,-6.112],[-37.256,-6.028],[-37.174,-6.048],[-37.157,-6.152],[-37.273,-6.28],[-37.363,-6.322],[-37.396,-6.51],[-37.465,-6.533],[-37.485,-6.71],[-37.314,-6.689],[-37.255,-6.811],[-37.193,-6.826],[-37.004,-6.71],[-36.959,-6.783],[-36.835,-6.731],[-36.73,-6.836],[-36.766,-6.936],[-36.604,-6.93],[-36.505,-6.754],[-36.559,-6.708],[-36.514,-6.594],[-36.438,-6.629],[-36.442,-6.565],[-36.526,-6.501],[-36.5,-6.358],[-36.433,-6.363],[-36.395,-6.294],[-36.295,-6.292],[-36.292,-6.396],[-36.226,-6.434],[-36.077,-6.406],[-36.005,-6.481],[-35.773,-6.483],[-35.664,-6.447],[-35.565,-6.492],[-35.408,-6.49],[-35.341,-6.539],[-35.224,-6.522],[-35.146,-6.553],[-34.968,-6.487],[-34.953,-6.673],[-34.869,-6.978],[-34.793,-7.155],[-34.794,-7.333],[-34.834,-7.549]]]}}, +{"type":"Feature","properties":{"uf":"PE","name":"Pernambuco"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-41.358,-8.708],[-41.213,-8.644],[-40.978,-8.399],[-40.819,-8.361],[-40.784,-8.262],[-40.711,-8.225],[-40.604,-8.104],[-40.544,-7.962],[-40.555,-7.818],[-40.674,-7.762],[-40.622,-7.658],[-40.715,-7.481],[-40.548,-7.393],[-40.524,-7.318],[-40.449,-7.361],[-40.361,-7.356],[-40.263,-7.301],[-40.128,-7.382],[-40.001,-7.374],[-39.858,-7.325],[-39.741,-7.327],[-39.572,-7.43],[-39.546,-7.484],[-39.461,-7.471],[-39.473,-7.573],[-39.253,-7.705],[-39.147,-7.718],[-39.088,-7.833],[-38.962,-7.844],[-38.94,-7.759],[-38.715,-7.622],[-38.594,-7.754],[-38.409,-7.73],[-38.327,-7.694],[-38.275,-7.834],[-38.164,-7.78],[-38.077,-7.831],[-38.04,-7.748],[-37.958,-7.766],[-37.857,-7.652],[-37.746,-7.609],[-37.656,-7.523],[-37.528,-7.465],[-37.496,-7.365],[-37.386,-7.357],[-37.328,-7.288],[-37.234,-7.274],[-37.168,-7.332],[-37.014,-7.404],[-36.985,-7.476],[-37.118,-7.534],[-37.198,-7.635],[-37.139,-7.745],[-37.34,-7.98],[-37.16,-7.997],[-37.16,-8.17],[-37.082,-8.262],[-36.991,-8.303],[-36.862,-8.222],[-36.687,-8.156],[-36.629,-8.111],[-36.612,-7.949],[-36.572,-7.904],[-36.48,-7.925],[-36.43,-7.827],[-36.217,-7.764],[-36.003,-7.813],[-35.527,-7.645],[-35.495,-7.484],[-35.249,-7.375],[-35.07,-7.399],[-34.985,-7.462],[-34.961,-7.539],[-34.834,-7.549],[-34.809,-7.63],[-34.845,-7.816],[-34.828,-7.959],[-34.909,-8.155],[-34.961,-8.396],[-35.088,-8.691],[-35.086,-8.747],[-35.153,-8.914],[-35.409,-8.838],[-35.54,-8.821],[-35.748,-8.917],[-35.795,-8.848],[-35.911,-8.861],[-36.128,-8.958],[-36.121,-9.028],[-36.267,-9.102],[-36.265,-9.166],[-36.356,-9.218],[-36.432,-9.212],[-36.625,-9.332],[-36.762,-9.271],[-36.914,-9.296],[-36.937,-9.377],[-37.003,-9.353],[-37.091,-9.246],[-37.229,-9.227],[-37.247,-9.179],[-37.416,-9.007],[-37.525,-8.963],[-37.658,-8.993],[-37.755,-8.846],[-37.826,-8.889],[-37.853,-9],[-37.979,-9.148],[-38.092,-9.173],[-38.238,-9.33],[-38.326,-9.081],[-38.323,-8.99],[-38.403,-9.037],[-38.503,-8.981],[-38.47,-8.866],[-38.57,-8.83],[-38.635,-8.987],[-38.706,-8.862],[-38.807,-8.788],[-38.952,-8.804],[-39.033,-8.736],[-39.234,-8.705],[-39.286,-8.564],[-39.38,-8.533],[-39.614,-8.657],[-39.689,-8.661],[-39.673,-8.785],[-39.893,-8.829],[-39.874,-8.937],[-39.955,-9.046],[-40.066,-9.063],[-40.13,-9.11],[-40.239,-9.054],[-40.273,-9.082],[-40.309,-9.275],[-40.356,-9.377],[-40.418,-9.352],[-40.623,-9.483],[-40.767,-9.445],[-40.67,-9.186],[-40.821,-9.08],[-40.851,-8.954],[-40.921,-8.835],[-41.021,-8.844],[-41.103,-8.78],[-41.131,-8.704],[-41.278,-8.735],[-41.358,-8.708]]],[[[-32.431,-3.845],[-32.431,-3.845],[-32.431,-3.845],[-32.431,-3.845]]]]}}, +{"type":"Feature","properties":{"uf":"AL","name":"Alagoas"},"geometry":{"type":"Polygon","coordinates":[[[-38.238,-9.33],[-38.092,-9.173],[-37.979,-9.148],[-37.853,-9],[-37.826,-8.889],[-37.755,-8.846],[-37.658,-8.993],[-37.525,-8.963],[-37.416,-9.007],[-37.247,-9.179],[-37.229,-9.227],[-37.091,-9.246],[-37.003,-9.353],[-36.937,-9.377],[-36.914,-9.296],[-36.762,-9.271],[-36.625,-9.332],[-36.432,-9.212],[-36.356,-9.218],[-36.265,-9.166],[-36.267,-9.102],[-36.121,-9.028],[-36.128,-8.958],[-35.911,-8.861],[-35.795,-8.848],[-35.748,-8.917],[-35.54,-8.821],[-35.409,-8.838],[-35.153,-8.914],[-35.295,-9.171],[-35.396,-9.301],[-35.472,-9.358],[-35.708,-9.665],[-35.814,-9.731],[-36.045,-10.074],[-36.26,-10.264],[-36.396,-10.497],[-36.463,-10.408],[-36.562,-10.416],[-36.572,-10.316],[-36.632,-10.252],[-36.72,-10.265],[-36.918,-10.125],[-36.962,-9.996],[-37.065,-9.981],[-37.162,-9.894],[-37.245,-9.892],[-37.347,-9.791],[-37.572,-9.739],[-37.71,-9.63],[-37.786,-9.639],[-37.894,-9.539],[-38.003,-9.515],[-38.078,-9.441],[-38.157,-9.44],[-38.238,-9.33]]]}}, +{"type":"Feature","properties":{"uf":"SE","name":"Sergipe"},"geometry":{"type":"Polygon","coordinates":[[[-38.003,-9.515],[-37.894,-9.539],[-37.786,-9.639],[-37.71,-9.63],[-37.572,-9.739],[-37.347,-9.791],[-37.245,-9.892],[-37.162,-9.894],[-37.065,-9.981],[-36.962,-9.996],[-36.918,-10.125],[-36.72,-10.265],[-36.632,-10.252],[-36.572,-10.316],[-36.562,-10.416],[-36.463,-10.408],[-36.396,-10.497],[-36.613,-10.588],[-36.784,-10.688],[-36.953,-10.851],[-37.26,-11.29],[-37.341,-11.442],[-37.52,-11.549],[-37.632,-11.518],[-37.682,-11.562],[-37.8,-11.521],[-37.87,-11.435],[-37.936,-11.422],[-37.994,-11.343],[-37.981,-11.199],[-38.047,-11.182],[-38.102,-11.017],[-38.169,-10.99],[-38.236,-10.882],[-38.205,-10.711],[-38.042,-10.701],[-37.972,-10.757],[-37.804,-10.651],[-37.839,-10.381],[-37.743,-10.338],[-37.747,-10.238],[-37.802,-10.113],[-37.783,-10.044],[-37.909,-9.94],[-38.003,-9.905],[-38.001,-9.816],[-38.061,-9.588],[-38.003,-9.515]]]}}, +{"type":"Feature","properties":{"uf":"BA","name":"Bahia"},"geometry":{"type":"Polygon","coordinates":[[[-41.358,-8.708],[-41.278,-8.735],[-41.131,-8.704],[-41.103,-8.78],[-41.021,-8.844],[-40.921,-8.835],[-40.851,-8.954],[-40.821,-9.08],[-40.67,-9.186],[-40.767,-9.445],[-40.623,-9.483],[-40.418,-9.352],[-40.356,-9.377],[-40.309,-9.275],[-40.273,-9.082],[-40.239,-9.054],[-40.13,-9.11],[-40.066,-9.063],[-39.955,-9.046],[-39.874,-8.937],[-39.893,-8.829],[-39.673,-8.785],[-39.689,-8.661],[-39.614,-8.657],[-39.38,-8.533],[-39.286,-8.564],[-39.234,-8.705],[-39.033,-8.736],[-38.952,-8.804],[-38.807,-8.788],[-38.706,-8.862],[-38.635,-8.987],[-38.57,-8.83],[-38.47,-8.866],[-38.503,-8.981],[-38.403,-9.037],[-38.323,-8.99],[-38.326,-9.081],[-38.238,-9.33],[-38.157,-9.44],[-38.078,-9.441],[-38.003,-9.515],[-38.061,-9.588],[-38.001,-9.816],[-38.003,-9.905],[-37.909,-9.94],[-37.783,-10.044],[-37.802,-10.113],[-37.747,-10.238],[-37.743,-10.338],[-37.839,-10.381],[-37.804,-10.651],[-37.972,-10.757],[-38.042,-10.701],[-38.205,-10.711],[-38.236,-10.882],[-38.169,-10.99],[-38.102,-11.017],[-38.047,-11.182],[-37.981,-11.199],[-37.994,-11.343],[-37.936,-11.422],[-37.87,-11.435],[-37.8,-11.521],[-37.682,-11.562],[-37.632,-11.518],[-37.52,-11.549],[-37.341,-11.442],[-37.513,-11.741],[-37.61,-11.956],[-37.77,-12.239],[-38.001,-12.578],[-38.244,-12.852],[-38.353,-12.957],[-38.469,-13.015],[-38.643,-13.015],[-38.762,-13.119],[-38.919,-13.214],[-38.967,-13.321],[-38.892,-13.461],[-38.933,-13.546],[-38.898,-13.666],[-38.968,-13.672],[-39,-13.776],[-38.929,-13.907],[-39.001,-14.346],[-39.062,-14.625],[-39.062,-14.747],[-39.024,-14.805],[-38.998,-14.987],[-38.991,-15.288],[-38.952,-15.469],[-38.935,-15.665],[-38.853,-15.849],[-39.011,-16.246],[-39.009,-16.346],[-39.062,-16.436],[-39.104,-16.698],[-39.14,-16.753],[-39.117,-16.892],[-39.212,-17.161],[-39.218,-17.335],[-39.184,-17.615],[-39.137,-17.685],[-39.268,-17.863],[-39.407,-17.922],[-39.491,-17.998],[-39.635,-18.232],[-39.668,-18.337],[-40.222,-17.98],[-40.263,-17.922],[-40.174,-17.852],[-40.224,-17.734],[-40.273,-17.727],[-40.344,-17.616],[-40.418,-17.599],[-40.519,-17.447],[-40.597,-17.421],[-40.595,-17.324],[-40.548,-17.284],[-40.579,-17.15],[-40.543,-16.983],[-40.48,-16.877],[-40.282,-16.901],[-40.251,-16.831],[-40.324,-16.801],[-40.275,-16.573],[-40.173,-16.577],[-40.11,-16.449],[-40.066,-16.457],[-39.99,-16.312],[-39.918,-16.284],[-39.857,-16.114],[-39.915,-16],[-40.004,-16.002],[-40.075,-15.92],[-40.16,-15.908],[-40.236,-15.803],[-40.425,-15.806],[-40.45,-15.762],[-40.561,-15.804],[-40.701,-15.667],[-40.817,-15.648],[-40.903,-15.692],[-40.963,-15.648],[-41.006,-15.714],[-41.142,-15.772],[-41.215,-15.737],[-41.33,-15.743],[-41.356,-15.5],[-41.802,-15.1],[-41.953,-15.176],[-42.096,-15.182],[-42.155,-15.098],[-42.266,-15.125],[-42.443,-15.061],[-42.586,-14.932],[-42.649,-14.93],[-42.795,-14.837],[-42.953,-14.708],[-43.175,-14.651],[-43.424,-14.72],[-43.531,-14.815],[-43.615,-14.76],[-43.847,-14.672],[-43.881,-14.562],[-43.783,-14.339],[-44.107,-14.259],[-44.341,-14.249],[-44.488,-14.329],[-44.566,-14.341],[-44.811,-14.503],[-44.869,-14.593],[-45.048,-14.69],[-45.096,-14.753],[-45.166,-14.735],[-45.49,-14.961],[-45.536,-14.938],[-45.74,-15.12],[-45.872,-15.159],[-45.961,-15.144],[-46.058,-15.263],[-46.116,-15.205],[-46.048,-15.164],[-45.975,-15.037],[-45.966,-14.966],[-46.039,-14.875],[-46.003,-14.796],[-45.981,-14.532],[-46.019,-14.472],[-45.907,-14.355],[-46.061,-14.195],[-46.187,-14.153],[-46.221,-14.107],[-46.22,-13.975],[-46.264,-13.947],[-46.232,-13.849],[-46.277,-13.791],[-46.223,-13.765],[-46.256,-13.684],[-46.163,-13.604],[-46.232,-13.583],[-46.232,-13.419],[-46.135,-13.375],[-46.068,-13.295],[-46.103,-13.257],[-46.292,-13.317],[-46.3,-13.128],[-46.275,-13.02],[-46.125,-12.96],[-46.113,-12.918],[-46.308,-12.942],[-46.268,-12.716],[-46.307,-12.637],[-46.266,-12.499],[-46.3,-12.364],[-46.37,-12.347],[-46.388,-12.249],[-46.371,-12.1],[-46.422,-12.029],[-46.248,-11.919],[-46.38,-11.867],[-46.374,-11.768],[-46.308,-11.687],[-46.341,-11.61],[-46.244,-11.605],[-46.155,-11.65],[-46.081,-11.624],[-46.209,-11.585],[-46.35,-11.506],[-46.439,-11.521],[-46.524,-11.464],[-46.576,-11.342],[-46.553,-11.265],[-46.342,-10.939],[-46.267,-10.935],[-46.26,-10.812],[-46.206,-10.798],[-46.128,-10.621],[-46.061,-10.6],[-45.82,-10.456],[-45.798,-10.323],[-45.699,-10.259],[-45.723,-10.155],[-45.603,-10.108],[-45.522,-10.277],[-45.43,-10.359],[-45.396,-10.445],[-45.447,-10.509],[-45.437,-10.616],[-45.358,-10.732],[-45.246,-10.822],[-45.082,-10.839],[-44.931,-10.929],[-44.808,-10.866],[-44.788,-10.808],[-44.667,-10.759],[-44.669,-10.687],[-44.578,-10.627],[-44.5,-10.65],[-44.344,-10.55],[-44.267,-10.623],[-44.139,-10.64],[-44.072,-10.58],[-44.028,-10.412],[-43.937,-10.436],[-43.837,-10.302],[-43.759,-10.075],[-43.699,-10.078],[-43.665,-9.991],[-43.709,-9.913],[-43.652,-9.844],[-43.785,-9.762],[-43.853,-9.562],[-43.782,-9.453],[-43.689,-9.42],[-43.579,-9.32],[-43.438,-9.266],[-43.285,-9.42],[-43.194,-9.421],[-43.122,-9.373],[-42.959,-9.428],[-42.944,-9.521],[-42.828,-9.56],[-42.77,-9.619],[-42.716,-9.554],[-42.59,-9.498],[-42.493,-9.495],[-42.316,-9.317],[-41.972,-9.249],[-41.922,-9.279],[-41.838,-9.242],[-41.731,-9.028],[-41.606,-8.957],[-41.546,-8.962],[-41.488,-8.844],[-41.358,-8.708]]]}}, +{"type":"Feature","properties":{"uf":"MG","name":"Minas Gerais"},"geometry":{"type":"Polygon","coordinates":[[[-50.935,-19.467],[-50.849,-19.498],[-50.875,-19.423],[-50.817,-19.289],[-50.674,-19.136],[-50.586,-19.139],[-50.495,-19.013],[-50.512,-18.952],[-50.37,-18.804],[-50.314,-18.701],[-50.164,-18.664],[-50.08,-18.672],[-50.029,-18.601],[-49.795,-18.644],[-49.5,-18.491],[-49.406,-18.645],[-49.293,-18.564],[-49.126,-18.384],[-49.079,-18.417],[-48.918,-18.306],[-48.832,-18.346],[-48.561,-18.324],[-48.469,-18.371],[-48.318,-18.367],[-48.272,-18.329],[-48.094,-18.431],[-48.047,-18.418],[-47.955,-18.5],[-47.83,-18.411],[-47.769,-18.418],[-47.61,-18.316],[-47.567,-18.211],[-47.43,-18.165],[-47.283,-18.041],[-47.357,-17.894],[-47.355,-17.815],[-47.265,-17.611],[-47.325,-17.524],[-47.467,-17.534],[-47.537,-17.457],[-47.511,-17.326],[-47.438,-17.318],[-47.348,-17.176],[-47.147,-17.032],[-47.129,-16.975],[-47.208,-16.875],[-47.252,-16.664],[-47.431,-16.549],[-47.459,-16.504],[-47.427,-16.399],[-47.322,-16.231],[-47.352,-16.134],[-47.308,-16.05],[-47.31,-16.036],[-47.226,-16.016],[-47.137,-15.927],[-47.107,-15.958],[-46.825,-15.886],[-46.813,-15.787],[-46.855,-15.618],[-46.946,-15.563],[-46.93,-15.443],[-46.836,-15.326],[-46.937,-15.205],[-46.888,-15.112],[-46.925,-15.057],[-46.857,-15.01],[-46.625,-15.089],[-46.503,-15.051],[-46.531,-15.005],[-46.522,-14.851],[-46.564,-14.783],[-46.475,-14.705],[-46.322,-14.815],[-46.319,-14.9],[-46.172,-14.947],[-46.088,-14.936],[-46.039,-14.875],[-45.966,-14.966],[-45.975,-15.037],[-46.048,-15.164],[-46.116,-15.205],[-46.058,-15.263],[-45.961,-15.144],[-45.872,-15.159],[-45.74,-15.12],[-45.536,-14.938],[-45.49,-14.961],[-45.166,-14.735],[-45.096,-14.753],[-45.048,-14.69],[-44.869,-14.593],[-44.811,-14.503],[-44.566,-14.341],[-44.488,-14.329],[-44.341,-14.249],[-44.107,-14.259],[-43.783,-14.339],[-43.881,-14.562],[-43.847,-14.672],[-43.615,-14.76],[-43.531,-14.815],[-43.424,-14.72],[-43.175,-14.651],[-42.953,-14.708],[-42.795,-14.837],[-42.649,-14.93],[-42.586,-14.932],[-42.443,-15.061],[-42.266,-15.125],[-42.155,-15.098],[-42.096,-15.182],[-41.953,-15.176],[-41.802,-15.1],[-41.356,-15.5],[-41.33,-15.743],[-41.215,-15.737],[-41.142,-15.772],[-41.006,-15.714],[-40.963,-15.648],[-40.903,-15.692],[-40.817,-15.648],[-40.701,-15.667],[-40.561,-15.804],[-40.45,-15.762],[-40.425,-15.806],[-40.236,-15.803],[-40.16,-15.908],[-40.075,-15.92],[-40.004,-16.002],[-39.915,-16],[-39.857,-16.114],[-39.918,-16.284],[-39.99,-16.312],[-40.066,-16.457],[-40.11,-16.449],[-40.173,-16.577],[-40.275,-16.573],[-40.324,-16.801],[-40.251,-16.831],[-40.282,-16.901],[-40.48,-16.877],[-40.543,-16.983],[-40.579,-17.15],[-40.548,-17.284],[-40.595,-17.324],[-40.597,-17.421],[-40.519,-17.447],[-40.418,-17.599],[-40.344,-17.616],[-40.273,-17.727],[-40.224,-17.734],[-40.174,-17.852],[-40.263,-17.922],[-40.222,-17.98],[-40.345,-17.924],[-40.463,-17.928],[-40.517,-17.894],[-40.704,-18.024],[-40.798,-17.957],[-40.902,-17.986],[-40.773,-18.108],[-40.771,-18.156],[-40.909,-18.115],[-41.052,-18.165],[-41.159,-18.308],[-41.144,-18.405],[-41.182,-18.44],[-41.023,-18.457],[-41.051,-18.634],[-40.941,-18.691],[-40.917,-18.815],[-41.11,-18.839],[-41.232,-18.797],[-41.243,-18.854],[-41.166,-18.858],[-41.032,-18.991],[-41.072,-19.026],[-40.964,-19.122],[-40.924,-19.302],[-40.945,-19.461],[-41.041,-19.516],[-41.037,-19.57],[-41.168,-19.672],[-41.187,-19.891],[-41.298,-19.938],[-41.382,-20.189],[-41.416,-20.207],[-41.757,-20.207],[-41.782,-20.294],[-41.859,-20.373],[-41.803,-20.422],[-41.813,-20.653],[-41.875,-20.766],[-41.928,-20.794],[-41.979,-20.935],[-42.138,-20.96],[-42.08,-21.03],[-42.208,-21.179],[-42.19,-21.25],[-42.292,-21.459],[-42.257,-21.482],[-42.365,-21.646],[-42.267,-21.714],[-42.378,-21.748],[-42.788,-21.93],[-42.881,-21.959],[-43.075,-22.093],[-43.142,-22.104],[-43.131,-22.029],[-43.347,-22.004],[-43.464,-22.073],[-43.566,-22.087],[-43.776,-22.066],[-43.977,-22.146],[-44.101,-22.174],[-44.26,-22.268],[-44.433,-22.251],[-44.544,-22.332],[-44.662,-22.38],[-44.73,-22.361],[-44.809,-22.406],[-44.966,-22.475],[-45.124,-22.497],[-45.269,-22.611],[-45.4,-22.654],[-45.48,-22.59],[-45.58,-22.653],[-45.576,-22.602],[-45.812,-22.699],[-45.712,-22.77],[-45.768,-22.836],[-45.909,-22.83],[-46.008,-22.889],[-46.139,-22.923],[-46.141,-22.868],[-46.376,-22.877],[-46.333,-22.766],[-46.453,-22.728],[-46.469,-22.673],[-46.393,-22.663],[-46.415,-22.558],[-46.542,-22.494],[-46.555,-22.447],[-46.666,-22.415],[-46.723,-22.307],[-46.672,-22.178],[-46.613,-22.15],[-46.685,-22.037],[-46.618,-21.995],[-46.691,-21.838],[-46.631,-21.769],[-46.614,-21.676],[-46.565,-21.679],[-46.512,-21.565],[-46.519,-21.449],[-46.616,-21.43],[-46.648,-21.38],[-46.765,-21.36],[-46.933,-21.425],[-47.013,-21.419],[-46.997,-21.357],[-47.118,-21.186],[-47.144,-20.982],[-47.24,-20.885],[-47.169,-20.703],[-47.097,-20.644],[-47.154,-20.52],[-47.291,-20.45],[-47.298,-20.348],[-47.231,-20.219],[-47.262,-20.16],[-47.44,-20.049],[-47.463,-19.966],[-47.584,-19.997],[-47.64,-20.047],[-47.713,-19.978],[-47.851,-19.99],[-47.899,-20.126],[-47.981,-20.036],[-48.02,-20.124],[-48.105,-20.146],[-48.228,-20.028],[-48.219,-20.128],[-48.406,-20.113],[-48.593,-20.136],[-48.647,-20.167],[-48.822,-20.161],[-48.887,-20.275],[-48.867,-20.399],[-48.963,-20.403],[-48.986,-20.171],[-49.066,-20.153],[-49.121,-20.27],[-49.225,-20.305],[-49.299,-20.167],[-49.297,-19.96],[-49.454,-19.978],[-49.514,-19.914],[-49.854,-19.946],[-50.044,-19.916],[-50.106,-19.874],[-50.337,-19.869],[-50.469,-19.78],[-50.578,-19.816],[-50.658,-19.907],[-50.784,-19.937],[-50.969,-20.036],[-51,-20.085],[-51.038,-19.692],[-50.988,-19.589],[-50.924,-19.581],[-50.935,-19.467]]]}}, +{"type":"Feature","properties":{"uf":"ES","name":"Espírito Santo"},"geometry":{"type":"Polygon","coordinates":[[[-41.875,-20.766],[-41.813,-20.653],[-41.803,-20.422],[-41.859,-20.373],[-41.782,-20.294],[-41.757,-20.207],[-41.416,-20.207],[-41.382,-20.189],[-41.298,-19.938],[-41.187,-19.891],[-41.168,-19.672],[-41.037,-19.57],[-41.041,-19.516],[-40.945,-19.461],[-40.924,-19.302],[-40.964,-19.122],[-41.072,-19.026],[-41.032,-18.991],[-41.166,-18.858],[-41.243,-18.854],[-41.232,-18.797],[-41.11,-18.839],[-40.917,-18.815],[-40.941,-18.691],[-41.051,-18.634],[-41.023,-18.457],[-41.182,-18.44],[-41.144,-18.405],[-41.159,-18.308],[-41.052,-18.165],[-40.909,-18.115],[-40.771,-18.156],[-40.773,-18.108],[-40.902,-17.986],[-40.798,-17.957],[-40.704,-18.024],[-40.517,-17.894],[-40.463,-17.928],[-40.345,-17.924],[-40.222,-17.98],[-39.668,-18.337],[-39.728,-18.518],[-39.746,-18.706],[-39.743,-18.936],[-39.689,-19.305],[-39.73,-19.468],[-39.811,-19.65],[-39.912,-19.687],[-40.055,-19.815],[-40.159,-20.036],[-40.198,-20.216],[-40.274,-20.32],[-40.431,-20.637],[-40.464,-20.624],[-40.583,-20.801],[-40.759,-20.866],[-40.858,-21.128],[-40.926,-21.19],[-40.961,-21.301],[-41.089,-21.22],[-41.19,-21.249],[-41.388,-21.199],[-41.435,-21.216],[-41.736,-21.103],[-41.71,-20.991],[-41.74,-20.817],[-41.875,-20.766]]]}}, +{"type":"Feature","properties":{"uf":"RJ","name":"Rio de Janeiro"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-44.724,-23.368],[-44.889,-23.227],[-44.824,-23.163],[-44.792,-22.982],[-44.555,-22.888],[-44.467,-22.885],[-44.268,-22.829],[-44.258,-22.767],[-44.174,-22.702],[-44.184,-22.637],[-44.382,-22.573],[-44.511,-22.64],[-44.646,-22.604],[-44.755,-22.415],[-44.809,-22.406],[-44.73,-22.361],[-44.662,-22.38],[-44.544,-22.332],[-44.433,-22.251],[-44.26,-22.268],[-44.101,-22.174],[-43.977,-22.146],[-43.776,-22.066],[-43.566,-22.087],[-43.464,-22.073],[-43.347,-22.004],[-43.131,-22.029],[-43.142,-22.104],[-43.075,-22.093],[-42.881,-21.959],[-42.788,-21.93],[-42.378,-21.748],[-42.267,-21.714],[-42.365,-21.646],[-42.257,-21.482],[-42.292,-21.459],[-42.19,-21.25],[-42.208,-21.179],[-42.08,-21.03],[-42.138,-20.96],[-41.979,-20.935],[-41.928,-20.794],[-41.875,-20.766],[-41.74,-20.817],[-41.71,-20.991],[-41.736,-21.103],[-41.435,-21.216],[-41.388,-21.199],[-41.19,-21.249],[-41.089,-21.22],[-40.961,-21.301],[-40.961,-21.36],[-41.073,-21.526],[-41.013,-21.623],[-41.025,-21.719],[-40.982,-21.911],[-41.004,-22.021],[-41.216,-22.137],[-41.451,-22.202],[-41.689,-22.3],[-41.773,-22.364],[-41.933,-22.537],[-41.997,-22.668],[-41.908,-22.778],[-42.02,-22.889],[-42.038,-22.967],[-42.281,-22.939],[-42.579,-22.933],[-43.014,-22.977],[-43.11,-22.954],[-43.134,-22.881],[-43.027,-22.743],[-43.081,-22.68],[-43.277,-22.78],[-43.15,-22.951],[-43.303,-23.016],[-43.42,-23.018],[-43.549,-23.061],[-43.796,-22.917],[-44.074,-22.952],[-44.113,-23.023],[-44.201,-23.038],[-44.303,-23.002],[-44.338,-22.923],[-44.44,-23.003],[-44.684,-23.072],[-44.695,-23.233],[-44.587,-23.232],[-44.527,-23.273],[-44.591,-23.362],[-44.639,-23.331],[-44.724,-23.368]]],[[[-44.35,-23.215],[-44.306,-23.121],[-44.216,-23.09],[-44.094,-23.175],[-44.35,-23.215]]],[[[-43.668,-23.053],[-43.954,-23.087],[-43.973,-23.045],[-43.668,-23.053]]],[[[-43.175,-22.801],[-43.175,-22.801],[-43.175,-22.801],[-43.175,-22.801]]]]}}, +{"type":"Feature","properties":{"uf":"SP","name":"São Paulo"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-51,-20.085],[-50.969,-20.036],[-50.784,-19.937],[-50.658,-19.907],[-50.578,-19.816],[-50.469,-19.78],[-50.337,-19.869],[-50.106,-19.874],[-50.044,-19.916],[-49.854,-19.946],[-49.514,-19.914],[-49.454,-19.978],[-49.297,-19.96],[-49.299,-20.167],[-49.225,-20.305],[-49.121,-20.27],[-49.066,-20.153],[-48.986,-20.171],[-48.963,-20.403],[-48.867,-20.399],[-48.887,-20.275],[-48.822,-20.161],[-48.647,-20.167],[-48.593,-20.136],[-48.406,-20.113],[-48.219,-20.128],[-48.228,-20.028],[-48.105,-20.146],[-48.02,-20.124],[-47.981,-20.036],[-47.899,-20.126],[-47.851,-19.99],[-47.713,-19.978],[-47.64,-20.047],[-47.584,-19.997],[-47.463,-19.966],[-47.44,-20.049],[-47.262,-20.16],[-47.231,-20.219],[-47.298,-20.348],[-47.291,-20.45],[-47.154,-20.52],[-47.097,-20.644],[-47.169,-20.703],[-47.24,-20.885],[-47.144,-20.982],[-47.118,-21.186],[-46.997,-21.357],[-47.013,-21.419],[-46.933,-21.425],[-46.765,-21.36],[-46.648,-21.38],[-46.616,-21.43],[-46.519,-21.449],[-46.512,-21.565],[-46.565,-21.679],[-46.614,-21.676],[-46.631,-21.769],[-46.691,-21.838],[-46.618,-21.995],[-46.685,-22.037],[-46.613,-22.15],[-46.672,-22.178],[-46.723,-22.307],[-46.666,-22.415],[-46.555,-22.447],[-46.542,-22.494],[-46.415,-22.558],[-46.393,-22.663],[-46.469,-22.673],[-46.453,-22.728],[-46.333,-22.766],[-46.376,-22.877],[-46.141,-22.868],[-46.139,-22.923],[-46.008,-22.889],[-45.909,-22.83],[-45.768,-22.836],[-45.712,-22.77],[-45.812,-22.699],[-45.576,-22.602],[-45.58,-22.653],[-45.48,-22.59],[-45.4,-22.654],[-45.269,-22.611],[-45.124,-22.497],[-44.966,-22.475],[-44.809,-22.406],[-44.755,-22.415],[-44.646,-22.604],[-44.511,-22.64],[-44.382,-22.573],[-44.184,-22.637],[-44.174,-22.702],[-44.258,-22.767],[-44.268,-22.829],[-44.467,-22.885],[-44.555,-22.888],[-44.792,-22.982],[-44.824,-23.163],[-44.889,-23.227],[-44.724,-23.368],[-44.84,-23.388],[-44.906,-23.334],[-45.054,-23.417],[-45.071,-23.497],[-45.174,-23.496],[-45.244,-23.594],[-45.311,-23.576],[-45.407,-23.624],[-45.427,-23.708],[-45.396,-23.808],[-45.502,-23.841],[-45.555,-23.795],[-45.842,-23.758],[-46.089,-23.822],[-46.198,-23.995],[-46.32,-24.024],[-46.355,-23.971],[-46.845,-24.218],[-46.994,-24.325],[-47.004,-24.409],[-47.245,-24.574],[-47.561,-24.758],[-47.731,-24.881],[-47.909,-25.053],[-47.924,-25.16],[-48.096,-25.309],[-48.023,-25.231],[-48.113,-25.251],[-48.182,-25.205],[-48.158,-25.15],[-48.24,-24.991],[-48.351,-25.036],[-48.412,-24.98],[-48.53,-25.101],[-48.598,-25.003],[-48.567,-24.972],[-48.556,-24.806],[-48.502,-24.746],[-48.617,-24.669],[-48.675,-24.702],[-48.84,-24.665],[-49.01,-24.654],[-49.208,-24.701],[-49.312,-24.663],[-49.316,-24.555],[-49.245,-24.465],[-49.201,-24.345],[-49.287,-24.303],[-49.351,-24.221],[-49.337,-24.138],[-49.409,-24.107],[-49.522,-23.917],[-49.587,-23.889],[-49.549,-23.704],[-49.621,-23.611],[-49.621,-23.493],[-49.582,-23.429],[-49.638,-23.349],[-49.627,-23.282],[-49.726,-23.109],[-49.912,-23.051],[-49.986,-22.897],[-50.133,-22.94],[-50.426,-22.944],[-50.682,-22.906],[-50.71,-22.953],[-50.793,-22.905],[-50.895,-22.794],[-51.004,-22.794],[-51.214,-22.724],[-51.265,-22.668],[-51.446,-22.653],[-51.56,-22.697],[-51.628,-22.658],[-51.718,-22.669],[-51.772,-22.614],[-51.856,-22.632],[-51.94,-22.567],[-52.109,-22.516],[-52.157,-22.645],[-52.223,-22.675],[-52.446,-22.608],[-52.499,-22.634],[-52.589,-22.566],[-52.697,-22.627],[-52.972,-22.57],[-53.105,-22.622],[-52.999,-22.493],[-52.855,-22.439],[-52.798,-22.378],[-52.488,-22.217],[-52.339,-22.048],[-52.319,-21.972],[-52.104,-21.746],[-52.049,-21.643],[-52.102,-21.57],[-52.057,-21.504],[-52,-21.517],[-51.929,-21.456],[-51.861,-21.337],[-51.865,-21.13],[-51.79,-21.102],[-51.714,-20.971],[-51.624,-20.944],[-51.635,-20.755],[-51.575,-20.623],[-51.5,-20.568],[-51.343,-20.356],[-51.161,-20.305],[-51.069,-20.25],[-51,-20.085]]],[[[-45.29,-23.869],[-45.238,-23.909],[-45.444,-23.934],[-45.46,-23.89],[-45.366,-23.809],[-45.34,-23.727],[-45.233,-23.785],[-45.29,-23.869]]]]}}, +{"type":"Feature","properties":{"uf":"PR","name":"Paraná"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-53.105,-22.622],[-52.972,-22.57],[-52.697,-22.627],[-52.589,-22.566],[-52.499,-22.634],[-52.446,-22.608],[-52.223,-22.675],[-52.157,-22.645],[-52.109,-22.516],[-51.94,-22.567],[-51.856,-22.632],[-51.772,-22.614],[-51.718,-22.669],[-51.628,-22.658],[-51.56,-22.697],[-51.446,-22.653],[-51.265,-22.668],[-51.214,-22.724],[-51.004,-22.794],[-50.895,-22.794],[-50.793,-22.905],[-50.71,-22.953],[-50.682,-22.906],[-50.426,-22.944],[-50.133,-22.94],[-49.986,-22.897],[-49.912,-23.051],[-49.726,-23.109],[-49.627,-23.282],[-49.638,-23.349],[-49.582,-23.429],[-49.621,-23.493],[-49.621,-23.611],[-49.549,-23.704],[-49.587,-23.889],[-49.522,-23.917],[-49.409,-24.107],[-49.337,-24.138],[-49.351,-24.221],[-49.287,-24.303],[-49.201,-24.345],[-49.245,-24.465],[-49.316,-24.555],[-49.312,-24.663],[-49.208,-24.701],[-49.01,-24.654],[-48.84,-24.665],[-48.675,-24.702],[-48.617,-24.669],[-48.502,-24.746],[-48.556,-24.806],[-48.567,-24.972],[-48.598,-25.003],[-48.53,-25.101],[-48.412,-24.98],[-48.351,-25.036],[-48.24,-24.991],[-48.158,-25.15],[-48.182,-25.205],[-48.113,-25.251],[-48.023,-25.231],[-48.096,-25.309],[-48.32,-25.587],[-48.404,-25.614],[-48.55,-25.852],[-48.595,-25.977],[-48.96,-25.981],[-49.167,-26.001],[-49.3,-26.111],[-49.603,-26.227],[-49.824,-26.094],[-49.869,-26.033],[-49.993,-26.011],[-50.19,-26.07],[-50.247,-26.03],[-50.332,-26.085],[-50.461,-26.026],[-50.628,-26.063],[-50.73,-26.203],[-50.9,-26.289],[-50.909,-26.249],[-51.077,-26.231],[-51.203,-26.298],[-51.293,-26.434],[-51.228,-26.555],[-51.239,-26.628],[-51.391,-26.663],[-51.411,-26.717],[-51.495,-26.587],[-51.658,-26.565],[-51.878,-26.598],[-52.009,-26.567],[-52.112,-26.475],[-52.23,-26.467],[-52.523,-26.4],[-52.596,-26.416],[-52.68,-26.353],[-52.993,-26.348],[-53.093,-26.392],[-53.304,-26.258],[-53.376,-26.242],[-53.496,-26.303],[-53.639,-26.251],[-53.741,-26.118],[-53.734,-26.043],[-53.842,-25.932],[-53.822,-25.799],[-53.878,-25.707],[-53.862,-25.659],[-53.961,-25.629],[-54.011,-25.566],[-54.12,-25.553],[-54.116,-25.498],[-54.203,-25.58],[-54.296,-25.558],[-54.425,-25.659],[-54.593,-25.592],[-54.619,-25.451],[-54.491,-25.278],[-54.425,-25.153],[-54.459,-25.102],[-54.39,-24.777],[-54.332,-24.694],[-54.326,-24.465],[-54.262,-24.375],[-54.346,-24.157],[-54.286,-24.068],[-54.102,-23.952],[-54.084,-23.805],[-53.987,-23.467],[-53.762,-23.359],[-53.637,-23.125],[-53.634,-23.003],[-53.547,-22.89],[-53.327,-22.765],[-53.185,-22.719],[-53.105,-22.622]]],[[[-53.654,-25.351],[-53.654,-25.351],[-53.654,-25.351],[-53.654,-25.351]]],[[[-53.378,-23.241],[-53.378,-23.241],[-53.378,-23.241],[-53.378,-23.241]]]]}}, +{"type":"Feature","properties":{"uf":"SC","name":"Santa Catarina"},"geometry":{"type":"Polygon","coordinates":[[[-53.639,-26.251],[-53.496,-26.303],[-53.376,-26.242],[-53.304,-26.258],[-53.093,-26.392],[-52.993,-26.348],[-52.68,-26.353],[-52.596,-26.416],[-52.523,-26.4],[-52.23,-26.467],[-52.112,-26.475],[-52.009,-26.567],[-51.878,-26.598],[-51.658,-26.565],[-51.495,-26.587],[-51.411,-26.717],[-51.391,-26.663],[-51.239,-26.628],[-51.228,-26.555],[-51.293,-26.434],[-51.203,-26.298],[-51.077,-26.231],[-50.909,-26.249],[-50.9,-26.289],[-50.73,-26.203],[-50.628,-26.063],[-50.461,-26.026],[-50.332,-26.085],[-50.247,-26.03],[-50.19,-26.07],[-49.993,-26.011],[-49.869,-26.033],[-49.824,-26.094],[-49.603,-26.227],[-49.3,-26.111],[-49.167,-26.001],[-48.96,-25.981],[-48.595,-25.977],[-48.598,-26.129],[-48.504,-26.237],[-48.651,-26.547],[-48.688,-26.671],[-48.62,-26.789],[-48.642,-26.913],[-48.571,-27.009],[-48.614,-27.1],[-48.553,-27.157],[-48.482,-27.153],[-48.603,-27.218],[-48.595,-27.316],[-48.54,-27.32],[-48.526,-27.408],[-48.417,-27.381],[-48.374,-27.472],[-48.507,-27.744],[-48.498,-27.795],[-48.596,-27.851],[-48.675,-28.256],[-48.765,-28.46],[-48.746,-28.505],[-48.828,-28.607],[-48.975,-28.671],[-49.188,-28.802],[-49.376,-28.956],[-49.516,-29.096],[-49.713,-29.326],[-49.857,-29.233],[-49.958,-29.199],[-50.145,-29.27],[-50.018,-29.163],[-49.958,-29.068],[-49.961,-28.772],[-49.757,-28.616],[-49.704,-28.63],[-49.748,-28.494],[-49.839,-28.496],[-49.971,-28.44],[-50.097,-28.484],[-50.242,-28.432],[-50.544,-28.427],[-50.749,-28.248],[-50.794,-28.139],[-50.859,-28.14],[-50.924,-27.972],[-51.001,-27.963],[-51.053,-27.869],[-51.135,-27.804],[-51.272,-27.752],[-51.315,-27.675],[-51.419,-27.655],[-51.483,-27.563],[-51.578,-27.577],[-51.633,-27.489],[-51.701,-27.48],[-51.837,-27.522],[-51.902,-27.491],[-51.983,-27.375],[-52.107,-27.343],[-52.126,-27.302],[-52.3,-27.319],[-52.48,-27.235],[-52.618,-27.266],[-52.851,-27.17],[-53,-27.207],[-53.018,-27.093],[-53.071,-27.157],[-53.322,-27.21],[-53.296,-27.132],[-53.381,-27.094],[-53.631,-27.213],[-53.686,-27.164],[-53.837,-27.168],[-53.799,-27.147],[-53.797,-27.039],[-53.672,-26.943],[-53.677,-26.844],[-53.74,-26.742],[-53.707,-26.395],[-53.639,-26.251]]]}}, +{"type":"Feature","properties":{"uf":"RS","name":"Rio Grande do Sul"},"geometry":{"type":"Polygon","coordinates":[[[-49.713,-29.326],[-49.934,-29.625],[-50.119,-29.976],[-50.306,-30.447],[-50.374,-30.563],[-50.767,-31.11],[-51.267,-31.579],[-51.502,-31.743],[-51.748,-31.87],[-51.921,-31.988],[-52.077,-32.143],[-52.005,-31.951],[-52.098,-31.836],[-52.008,-31.818],[-51.923,-31.863],[-51.811,-31.845],[-51.802,-31.788],[-51.659,-31.767],[-51.428,-31.492],[-51.361,-31.532],[-51.237,-31.457],[-51.157,-31.285],[-51.177,-31.08],[-51.104,-31.099],[-50.955,-31.003],[-50.968,-30.898],[-50.882,-30.892],[-50.754,-30.819],[-50.69,-30.707],[-50.679,-30.609],[-50.706,-30.348],[-50.574,-30.481],[-50.543,-30.252],[-50.659,-30.2],[-50.673,-30.297],[-50.782,-30.285],[-50.797,-30.338],[-50.915,-30.326],[-50.93,-30.436],[-51.047,-30.381],[-51.022,-30.284],[-51.128,-30.248],[-51.259,-30.123],[-51.234,-30.048],[-51.301,-30.054],[-51.328,-30.226],[-51.295,-30.303],[-51.178,-30.386],[-51.223,-30.42],[-51.319,-30.646],[-51.381,-30.643],[-51.404,-30.783],[-51.39,-30.88],[-51.498,-30.918],[-51.446,-31.089],[-51.602,-31.132],[-51.639,-31.271],[-51.919,-31.311],[-51.994,-31.414],[-52.031,-31.692],[-52.152,-31.699],[-52.226,-31.752],[-52.22,-31.873],[-52.139,-31.918],[-52.242,-31.992],[-52.113,-32.009],[-52.156,-32.096],[-52.099,-32.083],[-52.097,-32.162],[-52.234,-32.26],[-52.341,-32.425],[-52.423,-32.629],[-52.495,-32.867],[-52.624,-33.104],[-52.777,-33.283],[-53.156,-33.59],[-53.37,-33.744],[-53.523,-33.689],[-53.511,-33.544],[-53.466,-33.558],[-53.425,-33.438],[-53.469,-33.255],[-53.432,-33.162],[-53.337,-33.083],[-53.277,-33.109],[-53.174,-33.003],[-53.147,-32.819],[-53.002,-32.797],[-52.908,-32.888],[-52.832,-32.915],[-52.73,-32.847],[-52.621,-32.638],[-52.602,-32.461],[-52.688,-32.319],[-52.623,-32.146],[-52.732,-32.16],[-52.821,-32.334],[-52.724,-32.382],[-52.802,-32.447],[-52.983,-32.504],[-52.997,-32.591],[-53.084,-32.657],[-53.289,-32.621],[-53.437,-32.548],[-53.644,-32.385],[-53.643,-32.302],[-53.723,-32.174],[-53.748,-32.078],[-53.833,-32.055],[-53.963,-31.922],[-54.081,-31.93],[-54.455,-31.653],[-54.473,-31.57],[-54.587,-31.456],[-54.739,-31.426],[-54.836,-31.442],[-54.932,-31.385],[-55.002,-31.269],[-55.074,-31.332],[-55.247,-31.252],[-55.371,-31.023],[-55.437,-31.005],[-55.579,-30.84],[-55.642,-30.912],[-55.871,-31.072],[-56.005,-31.082],[-56.024,-30.786],[-56.15,-30.705],[-56.188,-30.605],[-56.386,-30.496],[-56.462,-30.384],[-56.491,-30.398],[-56.658,-30.201],[-56.696,-30.205],[-56.85,-30.089],[-57.078,-30.091],[-57.204,-30.285],[-57.311,-30.258],[-57.419,-30.303],[-57.568,-30.252],[-57.594,-30.179],[-57.465,-30.11],[-57.328,-29.972],[-57.33,-29.888],[-57.235,-29.781],[-57.132,-29.77],[-56.971,-29.643],[-56.902,-29.534],[-56.822,-29.49],[-56.759,-29.371],[-56.664,-29.295],[-56.589,-29.12],[-56.43,-29.079],[-56.379,-28.948],[-56.3,-28.896],[-56.3,-28.808],[-56.194,-28.775],[-56.002,-28.578],[-56.023,-28.523],[-55.884,-28.479],[-55.871,-28.357],[-55.757,-28.369],[-55.692,-28.416],[-55.669,-28.341],[-55.771,-28.241],[-55.564,-28.163],[-55.488,-28.076],[-55.375,-28.034],[-55.387,-27.986],[-55.278,-27.933],[-55.203,-27.858],[-55.025,-27.835],[-55.083,-27.797],[-54.949,-27.779],[-54.903,-27.636],[-54.785,-27.579],[-54.632,-27.546],[-54.573,-27.454],[-54.525,-27.506],[-54.399,-27.412],[-54.284,-27.448],[-54.193,-27.308],[-54.082,-27.299],[-53.947,-27.151],[-53.837,-27.168],[-53.686,-27.164],[-53.631,-27.213],[-53.381,-27.094],[-53.296,-27.132],[-53.322,-27.21],[-53.071,-27.157],[-53.018,-27.093],[-53,-27.207],[-52.851,-27.17],[-52.618,-27.266],[-52.48,-27.235],[-52.3,-27.319],[-52.126,-27.302],[-52.107,-27.343],[-51.983,-27.375],[-51.902,-27.491],[-51.837,-27.522],[-51.701,-27.48],[-51.633,-27.489],[-51.578,-27.577],[-51.483,-27.563],[-51.419,-27.655],[-51.315,-27.675],[-51.272,-27.752],[-51.135,-27.804],[-51.053,-27.869],[-51.001,-27.963],[-50.924,-27.972],[-50.859,-28.14],[-50.794,-28.139],[-50.749,-28.248],[-50.544,-28.427],[-50.242,-28.432],[-50.097,-28.484],[-49.971,-28.44],[-49.839,-28.496],[-49.748,-28.494],[-49.704,-28.63],[-49.757,-28.616],[-49.961,-28.772],[-49.958,-29.068],[-50.018,-29.163],[-50.145,-29.27],[-49.958,-29.199],[-49.857,-29.233],[-49.713,-29.326]]]}}, +{"type":"Feature","properties":{"uf":"MS","name":"Mato Grosso do Sul"},"geometry":{"type":"Polygon","coordinates":[[[-57.752,-17.564],[-57.712,-17.543],[-57.609,-17.797],[-57.485,-17.862],[-57.225,-17.783],[-57.101,-17.772],[-56.965,-17.622],[-56.973,-17.58],[-56.876,-17.533],[-56.758,-17.327],[-56.451,-17.326],[-56.336,-17.29],[-56.251,-17.219],[-56.044,-17.171],[-56.012,-17.237],[-55.761,-17.323],[-55.658,-17.339],[-55.515,-17.484],[-55.483,-17.479],[-55.137,-17.65],[-54.943,-17.609],[-54.861,-17.624],[-54.747,-17.52],[-54.57,-17.473],[-54.426,-17.537],[-54.388,-17.632],[-54.302,-17.662],[-54.192,-17.606],[-54.085,-17.619],[-54.058,-17.513],[-53.973,-17.472],[-53.82,-17.295],[-53.705,-17.228],[-53.68,-17.254],[-53.704,-17.661],[-53.856,-17.703],[-53.941,-17.84],[-53.952,-17.916],[-53.854,-17.926],[-53.774,-18.001],[-53.652,-17.976],[-53.468,-18.035],[-53.435,-17.985],[-53.292,-17.996],[-53.174,-18.042],[-53.072,-18.034],[-53.143,-18.084],[-53.1,-18.314],[-53.023,-18.349],[-52.965,-18.299],[-52.776,-18.329],[-52.798,-18.415],[-52.876,-18.431],[-52.962,-18.541],[-52.913,-18.642],[-52.748,-18.692],[-52.589,-18.687],[-52.531,-18.657],[-52.448,-18.692],[-52.305,-18.84],[-52.187,-18.846],[-52.079,-18.951],[-51.897,-18.994],[-51.66,-19.134],[-51.42,-19.166],[-51.092,-19.308],[-50.935,-19.467],[-50.924,-19.581],[-50.988,-19.589],[-51.038,-19.692],[-51,-20.085],[-51.069,-20.25],[-51.161,-20.305],[-51.343,-20.356],[-51.5,-20.568],[-51.575,-20.623],[-51.635,-20.755],[-51.624,-20.944],[-51.714,-20.971],[-51.79,-21.102],[-51.865,-21.13],[-51.861,-21.337],[-51.929,-21.456],[-52,-21.517],[-52.057,-21.504],[-52.102,-21.57],[-52.049,-21.643],[-52.104,-21.746],[-52.319,-21.972],[-52.339,-22.048],[-52.488,-22.217],[-52.798,-22.378],[-52.855,-22.439],[-52.999,-22.493],[-53.105,-22.622],[-53.185,-22.719],[-53.327,-22.765],[-53.547,-22.89],[-53.634,-23.003],[-53.637,-23.125],[-53.762,-23.359],[-53.987,-23.467],[-54.084,-23.805],[-54.102,-23.952],[-54.286,-24.068],[-54.44,-23.904],[-54.589,-23.839],[-54.684,-23.83],[-55.062,-23.993],[-55.107,-23.965],[-55.229,-24.014],[-55.318,-23.963],[-55.349,-23.994],[-55.446,-23.917],[-55.43,-23.79],[-55.475,-23.65],[-55.53,-23.628],[-55.56,-23.483],[-55.504,-23.378],[-55.556,-23.316],[-55.523,-23.198],[-55.596,-23.119],[-55.666,-22.852],[-55.62,-22.753],[-55.625,-22.628],[-55.724,-22.552],[-55.749,-22.383],[-55.804,-22.372],[-55.843,-22.287],[-56.009,-22.293],[-56.213,-22.276],[-56.317,-22.215],[-56.392,-22.08],[-56.519,-22.109],[-56.556,-22.195],[-56.652,-22.257],[-56.683,-22.221],[-56.844,-22.301],[-56.888,-22.245],[-56.997,-22.222],[-57.238,-22.251],[-57.389,-22.205],[-57.581,-22.175],[-57.613,-22.11],[-57.691,-22.088],[-57.748,-22.139],[-57.93,-22.12],[-57.992,-22.09],[-57.916,-21.876],[-57.96,-21.853],[-57.883,-21.689],[-57.962,-21.562],[-57.855,-21.317],[-57.92,-21.285],[-57.853,-21.227],[-57.866,-21.039],[-57.817,-20.981],[-57.929,-20.895],[-57.859,-20.825],[-57.959,-20.798],[-57.873,-20.751],[-57.914,-20.671],[-57.988,-20.687],[-58.013,-20.608],[-57.999,-20.456],[-58.073,-20.388],[-58.091,-20.285],[-58.166,-20.237],[-58.149,-20.152],[-57.963,-20.027],[-57.902,-20.042],[-57.859,-19.972],[-58.131,-19.759],[-57.783,-19.035],[-57.71,-19.035],[-57.72,-18.899],[-57.767,-18.899],[-57.558,-18.241],[-57.455,-18.233],[-57.574,-18.132],[-57.6,-18.043],[-57.721,-17.829],[-57.684,-17.809],[-57.783,-17.637],[-57.752,-17.564]]]}}, +{"type":"Feature","properties":{"uf":"MT","name":"Mato Grosso"},"geometry":{"type":"Polygon","coordinates":[[[-58.137,-7.356],[-58.061,-7.395],[-57.897,-7.678],[-57.835,-7.881],[-57.832,-7.961],[-57.642,-8.22],[-57.687,-8.407],[-57.633,-8.527],[-57.593,-8.756],[-57.417,-8.793],[-57.416,-8.859],[-57.289,-8.916],[-57.204,-8.921],[-57.039,-9.098],[-57.06,-9.182],[-56.995,-9.234],[-56.82,-9.246],[-56.754,-9.406],[-56.672,-9.367],[-54.613,-9.536],[-52.257,-9.708],[-51.304,-9.773],[-50.225,-9.841],[-50.303,-10.035],[-50.398,-10.155],[-50.382,-10.212],[-50.418,-10.355],[-50.474,-10.405],[-50.483,-10.49],[-50.603,-10.66],[-50.571,-10.752],[-50.633,-10.932],[-50.609,-11.067],[-50.664,-11.152],[-50.671,-11.287],[-50.742,-11.46],[-50.739,-11.544],[-50.657,-11.593],[-50.664,-11.677],[-50.722,-11.74],[-50.686,-11.859],[-50.639,-11.885],[-50.682,-11.991],[-50.681,-12.217],[-50.624,-12.455],[-50.7,-12.595],[-50.623,-12.735],[-50.625,-12.8],[-50.511,-12.861],[-50.524,-12.971],[-50.571,-13.034],[-50.585,-13.215],[-50.664,-13.435],[-50.759,-13.52],[-50.809,-13.697],[-50.874,-13.734],[-50.84,-13.924],[-50.866,-13.96],[-50.831,-14.083],[-50.899,-14.115],[-50.977,-14.293],[-50.998,-14.414],[-50.964,-14.528],[-51.09,-14.84],[-51.087,-14.921],[-51.276,-15.044],[-51.307,-14.981],[-51.339,-14.975],[-51.341,-14.98],[-51.353,-14.994],[-51.534,-15.065],[-51.647,-15.173],[-51.691,-15.325],[-51.699,-15.485],[-51.756,-15.557],[-51.768,-15.654],[-51.88,-15.824],[-51.948,-15.81],[-52.009,-15.886],[-52.254,-15.894],[-52.328,-16.07],[-52.549,-16.165],[-52.546,-16.26],[-52.659,-16.281],[-52.693,-16.366],[-52.606,-16.486],[-52.726,-16.614],[-52.832,-16.772],[-52.885,-16.777],[-52.972,-16.87],[-53.014,-16.864],[-53.064,-17.016],[-53.209,-17.316],[-53.248,-17.627],[-53.234,-17.713],[-53.16,-17.775],[-53.072,-18.034],[-53.174,-18.042],[-53.292,-17.996],[-53.435,-17.985],[-53.468,-18.035],[-53.652,-17.976],[-53.774,-18.001],[-53.854,-17.926],[-53.952,-17.916],[-53.941,-17.84],[-53.856,-17.703],[-53.704,-17.661],[-53.68,-17.254],[-53.705,-17.228],[-53.82,-17.295],[-53.973,-17.472],[-54.058,-17.513],[-54.085,-17.619],[-54.192,-17.606],[-54.302,-17.662],[-54.388,-17.632],[-54.426,-17.537],[-54.57,-17.473],[-54.747,-17.52],[-54.861,-17.624],[-54.943,-17.609],[-55.137,-17.65],[-55.483,-17.479],[-55.515,-17.484],[-55.658,-17.339],[-55.761,-17.323],[-56.012,-17.237],[-56.044,-17.171],[-56.251,-17.219],[-56.336,-17.29],[-56.451,-17.326],[-56.758,-17.327],[-56.876,-17.533],[-56.973,-17.58],[-56.965,-17.622],[-57.101,-17.772],[-57.225,-17.783],[-57.485,-17.862],[-57.609,-17.797],[-57.712,-17.543],[-57.752,-17.564],[-57.883,-17.449],[-57.996,-17.516],[-58.121,-17.448],[-58.153,-17.384],[-58.264,-17.345],[-58.399,-17.184],[-58.392,-17.04],[-58.477,-16.937],[-58.463,-16.653],[-58.344,-16.518],[-58.356,-16.426],[-58.307,-16.371],[-58.327,-16.27],[-58.389,-16.261],[-58.431,-16.323],[-59.469,-16.28],[-60.174,-16.267],[-60.239,-15.475],[-60.576,-15.097],[-60.245,-15.097],[-60.274,-14.621],[-60.323,-14.61],[-60.34,-14.51],[-60.406,-14.36],[-60.454,-14.314],[-60.481,-14.096],[-60.383,-13.993],[-60.451,-13.927],[-60.487,-13.819],[-60.623,-13.719],[-60.709,-13.693],[-60.686,-13.625],[-60.511,-13.504],[-60.388,-13.455],[-60.372,-13.319],[-60.284,-13.085],[-60.183,-12.968],[-60.117,-12.96],[-60.079,-12.881],[-60.029,-12.619],[-59.948,-12.599],[-59.844,-12.467],[-59.779,-12.341],[-59.887,-12.245],[-59.9,-12.117],[-59.962,-12.056],[-59.984,-11.915],[-60.066,-11.891],[-60.103,-11.819],[-60.115,-11.591],[-59.934,-11.424],[-59.917,-11.338],[-59.959,-11.295],[-59.995,-11.148],[-60.075,-11.105],[-60.191,-11.114],[-60.301,-11.057],[-60.392,-11.094],[-60.46,-10.99],[-61.001,-10.992],[-61.55,-10.986],[-61.525,-10.856],[-61.48,-10.788],[-61.499,-10.699],[-61.462,-10.42],[-61.577,-10.249],[-61.601,-10.15],[-61.508,-9.861],[-61.532,-9.712],[-61.477,-9.63],[-61.508,-9.54],[-61.582,-9.459],[-61.559,-9.385],[-61.628,-9.352],[-61.6,-9.256],[-61.525,-9.243],[-61.556,-9.092],[-61.469,-8.92],[-61.583,-8.799],[-60.42,-8.797],[-58.963,-8.803],[-58.442,-8.799],[-58.328,-8.712],[-58.437,-8.703],[-58.389,-8.595],[-58.418,-8.489],[-58.355,-8.393],[-58.293,-8.071],[-58.375,-7.899],[-58.378,-7.827],[-58.3,-7.777],[-58.202,-7.621],[-58.213,-7.458],[-58.137,-7.356]]]}}, +{"type":"Feature","properties":{"uf":"GO","name":"Goiás"},"geometry":{"type":"Polygon","coordinates":[[[-47.31,-16.036],[-47.37,-16.001],[-47.379,-15.887],[-47.315,-15.746],[-47.322,-15.598],[-47.418,-15.546],[-47.418,-15.502],[-48.2,-15.502],[-48.2,-15.623],[-48.241,-15.706],[-48.209,-15.75],[-48.282,-15.831],[-48.251,-15.956],[-48.278,-16.05],[-47.308,-16.05],[-47.352,-16.134],[-47.322,-16.231],[-47.427,-16.399],[-47.459,-16.504],[-47.431,-16.549],[-47.252,-16.664],[-47.208,-16.875],[-47.129,-16.975],[-47.147,-17.032],[-47.348,-17.176],[-47.438,-17.318],[-47.511,-17.326],[-47.537,-17.457],[-47.467,-17.534],[-47.325,-17.524],[-47.265,-17.611],[-47.355,-17.815],[-47.357,-17.894],[-47.283,-18.041],[-47.43,-18.165],[-47.567,-18.211],[-47.61,-18.316],[-47.769,-18.418],[-47.83,-18.411],[-47.955,-18.5],[-48.047,-18.418],[-48.094,-18.431],[-48.272,-18.329],[-48.318,-18.367],[-48.469,-18.371],[-48.561,-18.324],[-48.832,-18.346],[-48.918,-18.306],[-49.079,-18.417],[-49.126,-18.384],[-49.293,-18.564],[-49.406,-18.645],[-49.5,-18.491],[-49.795,-18.644],[-50.029,-18.601],[-50.08,-18.672],[-50.164,-18.664],[-50.314,-18.701],[-50.37,-18.804],[-50.512,-18.952],[-50.495,-19.013],[-50.586,-19.139],[-50.674,-19.136],[-50.817,-19.289],[-50.875,-19.423],[-50.849,-19.498],[-50.935,-19.467],[-51.092,-19.308],[-51.42,-19.166],[-51.66,-19.134],[-51.897,-18.994],[-52.079,-18.951],[-52.187,-18.846],[-52.305,-18.84],[-52.448,-18.692],[-52.531,-18.657],[-52.589,-18.687],[-52.748,-18.692],[-52.913,-18.642],[-52.962,-18.541],[-52.876,-18.431],[-52.798,-18.415],[-52.776,-18.329],[-52.965,-18.299],[-53.023,-18.349],[-53.1,-18.314],[-53.143,-18.084],[-53.072,-18.034],[-53.16,-17.775],[-53.234,-17.713],[-53.248,-17.627],[-53.209,-17.316],[-53.064,-17.016],[-53.014,-16.864],[-52.972,-16.87],[-52.885,-16.777],[-52.832,-16.772],[-52.726,-16.614],[-52.606,-16.486],[-52.693,-16.366],[-52.659,-16.281],[-52.546,-16.26],[-52.549,-16.165],[-52.328,-16.07],[-52.254,-15.894],[-52.009,-15.886],[-51.948,-15.81],[-51.88,-15.824],[-51.768,-15.654],[-51.756,-15.557],[-51.699,-15.485],[-51.691,-15.325],[-51.647,-15.173],[-51.534,-15.065],[-51.353,-14.994],[-51.341,-14.98],[-51.339,-14.975],[-51.307,-14.981],[-51.276,-15.044],[-51.087,-14.921],[-51.09,-14.84],[-50.964,-14.528],[-50.998,-14.414],[-50.977,-14.293],[-50.899,-14.115],[-50.831,-14.083],[-50.866,-13.96],[-50.84,-13.924],[-50.874,-13.734],[-50.809,-13.697],[-50.759,-13.52],[-50.664,-13.435],[-50.585,-13.215],[-50.571,-13.034],[-50.524,-12.971],[-50.511,-12.861],[-50.479,-12.713],[-50.305,-12.492],[-50.206,-12.445],[-50.193,-12.564],[-50.296,-12.711],[-50.286,-12.84],[-49.909,-12.975],[-49.368,-13.272],[-49.339,-13.253],[-49.355,-13.107],[-49.238,-12.884],[-49.118,-12.79],[-48.976,-12.957],[-48.87,-12.803],[-48.736,-12.921],[-48.576,-13.124],[-48.6,-13.288],[-48.554,-13.303],[-48.516,-13.139],[-48.462,-13.275],[-48.385,-13.264],[-48.235,-13.176],[-48.166,-13.189],[-48.156,-13.308],[-48.033,-13.274],[-47.798,-13.329],[-47.68,-13.468],[-47.624,-13.376],[-47.667,-13.208],[-47.646,-13.12],[-47.569,-13.117],[-47.565,-13.18],[-47.417,-13.271],[-47.38,-13.231],[-47.281,-13.265],[-47.225,-13.193],[-47.011,-13.141],[-46.751,-12.969],[-46.455,-12.971],[-46.418,-12.822],[-46.366,-12.863],[-46.371,-12.988],[-46.198,-12.957],[-46.113,-12.918],[-46.125,-12.96],[-46.275,-13.02],[-46.3,-13.128],[-46.292,-13.317],[-46.103,-13.257],[-46.068,-13.295],[-46.135,-13.375],[-46.232,-13.419],[-46.232,-13.583],[-46.163,-13.604],[-46.256,-13.684],[-46.223,-13.765],[-46.277,-13.791],[-46.232,-13.849],[-46.264,-13.947],[-46.22,-13.975],[-46.221,-14.107],[-46.187,-14.153],[-46.061,-14.195],[-45.907,-14.355],[-46.019,-14.472],[-45.981,-14.532],[-46.003,-14.796],[-46.039,-14.875],[-46.088,-14.936],[-46.172,-14.947],[-46.319,-14.9],[-46.322,-14.815],[-46.475,-14.705],[-46.564,-14.783],[-46.522,-14.851],[-46.531,-15.005],[-46.503,-15.051],[-46.625,-15.089],[-46.857,-15.01],[-46.925,-15.057],[-46.888,-15.112],[-46.937,-15.205],[-46.836,-15.326],[-46.93,-15.443],[-46.946,-15.563],[-46.855,-15.618],[-46.813,-15.787],[-46.825,-15.886],[-47.107,-15.958],[-47.137,-15.927],[-47.226,-16.016],[-47.31,-16.036]]]}}, +{"type":"Feature","properties":{"uf":"DF","name":"Distrito Federal"},"geometry":{"type":"Polygon","coordinates":[[[-47.308,-16.05],[-48.278,-16.05],[-48.251,-15.956],[-48.282,-15.831],[-48.209,-15.75],[-48.241,-15.706],[-48.2,-15.623],[-48.2,-15.502],[-47.418,-15.502],[-47.418,-15.546],[-47.322,-15.598],[-47.315,-15.746],[-47.379,-15.887],[-47.37,-16.001],[-47.31,-16.036],[-47.308,-16.05]]]}} +] +} diff --git a/app-modules/panel-admin/resources/views/discord/dashboard.blade.php b/app-modules/panel-admin/resources/views/discord/dashboard.blade.php new file mode 100644 index 000000000..c53d377bc --- /dev/null +++ b/app-modules/panel-admin/resources/views/discord/dashboard.blade.php @@ -0,0 +1 @@ + diff --git a/app-modules/panel-admin/resources/views/marketing/location-dashboard.blade.php b/app-modules/panel-admin/resources/views/marketing/location-dashboard.blade.php new file mode 100644 index 000000000..cc5ad1748 --- /dev/null +++ b/app-modules/panel-admin/resources/views/marketing/location-dashboard.blade.php @@ -0,0 +1,4 @@ + + {{-- Content is composed of header widgets (stats, map, top states), + laid out by Filament's native widget grid — see LocationDashboard. --}} + diff --git a/app-modules/panel-admin/resources/views/marketing/widgets/location-map.blade.php b/app-modules/panel-admin/resources/views/marketing/widgets/location-map.blade.php new file mode 100644 index 000000000..fe7a00e09 --- /dev/null +++ b/app-modules/panel-admin/resources/views/marketing/widgets/location-map.blade.php @@ -0,0 +1,181 @@ +{{-- Chart.js + chartjs-chart-geo via CDN, fixados por SRI (integrity). + Ao bumpar a versão nas URLs, recalcule o hash, senão o script para de carregar: + curl -sSL | openssl dgst -sha384 -binary | openssl base64 -A --}} +@assets + + +@endassets + + + + {{ __('panel-admin::location.map.heading') }} + {{ __('panel-admin::location.map.hint') }} + + + +
+ +
+ + + + +
+
diff --git a/app-modules/panel-admin/resources/views/marketing/widgets/top-states.blade.php b/app-modules/panel-admin/resources/views/marketing/widgets/top-states.blade.php new file mode 100644 index 000000000..4d3caf26c --- /dev/null +++ b/app-modules/panel-admin/resources/views/marketing/widgets/top-states.blade.php @@ -0,0 +1,39 @@ + + + {{ __('panel-admin::location.top.heading') }} + {{ __('panel-admin::location.top.hint') }} + +
+ @forelse ($top as $index => $row) +
+ + {{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }} + +
+
+ {{ $row->name }} + {{ number_format($row->members) }} +
+
+
+
+
+ + {{ number_format($row->share, 1) }}% + +
+ @empty +

{{ __('panel-admin::location.top.empty') }}

+ @endforelse + + @if ($statesReached > count($top)) +
+ {{ __('panel-admin::location.top.others', ['count' => $statesReached - count($top)]) }} +
+ @endif +
+
+
diff --git a/app-modules/panel-admin/resources/views/twitch/register-subscriptions-modal.blade.php b/app-modules/panel-admin/resources/views/twitch/register-subscriptions-modal.blade.php index fb2651596..8368de7f7 100644 --- a/app-modules/panel-admin/resources/views/twitch/register-subscriptions-modal.blade.php +++ b/app-modules/panel-admin/resources/views/twitch/register-subscriptions-modal.blade.php @@ -15,8 +15,8 @@
-

No Twitch channel linked

-

Connect a Twitch channel to this tenant before registering subscriptions. Use the Tenant Settings page or run twitch:link-channel.

+

No Twitch account connected

+

Connect your Twitch account with the “Connect Twitch” button on this page before registering subscriptions.

@else diff --git a/app-modules/panel-admin/src/Discord/DiscordCluster.php b/app-modules/panel-admin/src/Discord/DiscordCluster.php new file mode 100644 index 000000000..9533cce38 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/DiscordCluster.php @@ -0,0 +1,30 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListDiscordChannels::route('/'), + 'view' => ViewDiscordChannel::route('/{record}'), + ]; + } + + /** + * @return Builder + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + /** @var Builder $query */ + $query = parent::getGlobalSearchEloquentQuery()->with(['guild', 'parent']); + + return $query; + } + + public static function getGloballySearchableAttributes(): array + { + return ['name', 'guild.name', 'parent.name']; + } + + /** + * @param DiscordChannel $record + * @return array + */ + public static function getGlobalSearchResultDetails(Model $record): array + { + $details = [ + 'Guild' => $record->guild->name, + ]; + + if ($record->parent !== null) { + $details['Parent'] = $record->parent->name; + } + + return $details; + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordChannels/Pages/ListDiscordChannels.php b/app-modules/panel-admin/src/Discord/Resources/DiscordChannels/Pages/ListDiscordChannels.php new file mode 100644 index 000000000..88f4d3501 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordChannels/Pages/ListDiscordChannels.php @@ -0,0 +1,13 @@ +columns(1) + ->components([ + Section::make(__('panel-admin::discord.channels.sections.channel')) + ->columns(2) + ->schema([ + TextEntry::make('name') + ->label(__('panel-admin::discord.channels.fields.name')) + ->weight(FontWeight::Bold), + + TextEntry::make('type') + ->label(__('panel-admin::discord.channels.fields.type')) + ->badge(), + + TextEntry::make('topic') + ->label(__('panel-admin::discord.channels.fields.topic')) + ->columnSpanFull() + ->placeholder('—'), + + TextEntry::make('parent.name') + ->label(__('panel-admin::discord.channels.fields.category')) + ->placeholder('—'), + + TextEntry::make('guild.name') + ->label(__('panel-admin::discord.channels.fields.guild')) + ->url(fn (DiscordChannel $record): string => DiscordGuildResource::getUrl('view', ['record' => $record->guild])) + ->color('primary'), + + TextEntry::make('discord_channel_id') + ->label(__('panel-admin::discord.channels.fields.discord_channel_id')) + ->copyable(), + ]), + + Section::make(__('panel-admin::discord.channels.sections.settings')) + ->columns(2) + ->collapsed() + ->schema([ + TextEntry::make('position') + ->label(__('panel-admin::discord.channels.fields.position')) + ->numeric(), + + IconEntry::make('nsfw') + ->label(__('panel-admin::discord.channels.fields.nsfw')) + ->boolean(), + + TextEntry::make('bitrate') + ->label(__('panel-admin::discord.channels.fields.bitrate')) + ->formatStateUsing(fn (?int $state): ?string => $state ? ($state / 1_000).' kbps' : null) + ->placeholder('—'), + + TextEntry::make('user_limit') + ->label(__('panel-admin::discord.channels.fields.user_limit')) + ->placeholder('—'), + + TextEntry::make('created_at') + ->label(__('panel-admin::discord.channels.fields.created_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')), + + TextEntry::make('updated_at') + ->label(__('panel-admin::discord.channels.fields.updated_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordChannels/Tables/DiscordChannelsTable.php b/app-modules/panel-admin/src/Discord/Resources/DiscordChannels/Tables/DiscordChannelsTable.php new file mode 100644 index 000000000..390d6a6be --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordChannels/Tables/DiscordChannelsTable.php @@ -0,0 +1,111 @@ +modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('type', '!=', DiscordChannelType::GuildCategory) + ->with('parent')) + // Cursor pagination (padrão global do painel) não suporta ORDER BY por + // expressão, e o agrupamento por parent.name ordena via subquery. + ->paginationMode(PaginationMode::Default) + ->defaultGroup( + Group::make('parent.name') + ->label(__('panel-admin::discord.channels.fields.category')) + ->getTitleFromRecordUsing(function (DiscordChannel $record): string { + $parent = $record->parent; + + return $parent === null + ? __('panel-admin::discord.channels.groups.uncategorized') + : $parent->name; + }) + // O relacionamento parent é self-referential; a ordenação padrão do + // Group gera subquery sem alias e colide com a tabela externa. + ->orderQueryUsing(fn (Builder $query, string $direction): Builder => $query->orderBy( + DiscordChannel::query() + ->from('discord_channels as parent_channels') + ->select('parent_channels.name') + ->whereColumn('parent_channels.id', 'discord_channels.parent_id'), + $direction === 'desc' ? 'desc' : 'asc', + )) + ->collapsible() + ) + ->groupingSettingsHidden() + ->defaultSort('position') + ->columns([ + TextColumn::make('name') + ->label(__('panel-admin::discord.channels.fields.name')) + ->searchable() + ->sortable() + ->weight(FontWeight::Medium) + ->description(fn (DiscordChannel $record): ?string => $record->topic ? Str::limit($record->topic, 80) : null), + + TextColumn::make('type') + ->label(__('panel-admin::discord.channels.fields.type')) + ->badge(), + + TextColumn::make('position') + ->label(__('panel-admin::discord.channels.fields.position')) + ->numeric() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('nsfw') + ->label(__('panel-admin::discord.channels.fields.nsfw')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('bitrate') + ->label(__('panel-admin::discord.channels.fields.bitrate')) + ->formatStateUsing(fn (?int $state): ?string => $state ? ($state / 1_000).' kbps' : null) + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('user_limit') + ->label(__('panel-admin::discord.channels.fields.user_limit')) + ->numeric() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('discord_channel_id') + ->label(__('panel-admin::discord.channels.fields.discord_channel_id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('type') + ->label(__('panel-admin::discord.channels.filters.type')) + ->options(collect(DiscordChannelType::cases()) + ->reject(fn (DiscordChannelType $type): bool => $type === DiscordChannelType::GuildCategory) + ->mapWithKeys(fn (DiscordChannelType $type): array => [$type->value => $type->getLabel()]) + ->all()) + ->multiple(), + + TernaryFilter::make('nsfw') + ->label(__('panel-admin::discord.channels.filters.nsfw')), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/DiscordEventLogResource.php b/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/DiscordEventLogResource.php new file mode 100644 index 000000000..eb760befd --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/DiscordEventLogResource.php @@ -0,0 +1,79 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListDiscordEventLogs::route('/'), + 'view' => ViewDiscordEventLog::route('/{record}'), + ]; + } + + public static function getGloballySearchableAttributes(): array + { + return []; + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/Pages/ListDiscordEventLogs.php b/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/Pages/ListDiscordEventLogs.php new file mode 100644 index 000000000..4a910fce2 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/Pages/ListDiscordEventLogs.php @@ -0,0 +1,13 @@ +columns(1) + ->components([ + Section::make(__('panel-admin::discord.event_logs.sections.event')) + ->columns(2) + ->schema([ + TextEntry::make('event_type') + ->label(__('panel-admin::discord.event_logs.fields.event_type')) + ->badge() + ->formatStateUsing(fn (string $state): string => DiscordEventType::tryFrom($state)?->getLabel() ?? $state) + ->color(fn (string $state): string => DiscordEventType::tryFrom($state)?->getColor() ?? 'gray') + ->icon(fn (string $state) => DiscordEventType::tryFrom($state)?->getIcon()) + ->tooltip(fn (string $state): string => $state), + + TextEntry::make('created_at') + ->label(__('panel-admin::discord.event_logs.fields.created_at')) + ->dateTime('d/m/Y H:i:s') + ->timezone(config('app.display_timezone')), + + TextEntry::make('guild_id') + ->label(__('panel-admin::discord.event_logs.fields.guild_id')) + ->copyable() + ->placeholder('—'), + + TextEntry::make('channel_id') + ->label(__('panel-admin::discord.event_logs.fields.channel_id')) + ->copyable() + ->placeholder('—'), + + TextEntry::make('user_id') + ->label(__('panel-admin::discord.event_logs.fields.user_id')) + ->copyable() + ->placeholder('—') + ->url(fn (DiscordEventLog $record): ?string => filled($record->user_id) && ($member = DiscordMember::query()->where('discord_user_id', $record->user_id)->first()) + ? DiscordMemberResource::getUrl('view', ['record' => $member]) + : null) + ->color(fn (DiscordEventLog $record): string => filled($record->user_id) ? 'primary' : 'gray'), + ]), + + Section::make(__('panel-admin::discord.event_logs.sections.payload')) + ->schema([ + CodeEntry::make('payload') + ->label(__('panel-admin::discord.event_logs.fields.payload')) + ->jsonFlags(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ->extraAttributes(['class' => 'overflow-auto max-h-128']) + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/Tables/DiscordEventLogsTable.php b/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/Tables/DiscordEventLogsTable.php new file mode 100644 index 000000000..3c3a442c4 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordEventLogs/Tables/DiscordEventLogsTable.php @@ -0,0 +1,99 @@ +deferLoading() + ->defaultSort('created_at', 'desc') + ->paginated([25, 50, 100]) + ->columns([ + TextColumn::make('event_type') + ->label(__('panel-admin::discord.event_logs.fields.event_type')) + ->badge() + ->formatStateUsing(fn (string $state): string => DiscordEventType::tryFrom($state)?->getLabel() ?? $state) + ->color(fn (string $state): string => DiscordEventType::tryFrom($state)?->getColor() ?? 'gray') + ->icon(fn (string $state) => DiscordEventType::tryFrom($state)?->getIcon()) + ->tooltip(fn (string $state): string => $state) + ->searchable() + ->sortable(), + + TextColumn::make('user_id') + ->label(__('panel-admin::discord.event_logs.fields.user_id')) + ->copyable() + ->placeholder('—') + ->formatStateUsing(fn (?string $state): ?string => $state !== '' ? $state : null), + + TextColumn::make('channel_id') + ->label(__('panel-admin::discord.event_logs.fields.channel_id')) + ->copyable() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('guild_id') + ->label(__('panel-admin::discord.event_logs.fields.guild_id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('created_at') + ->label(__('panel-admin::discord.event_logs.fields.created_at')) + ->dateTime('d/m/Y H:i:s') + ->timezone(config('app.display_timezone')) + ->sortable(), + ]) + ->filters([ + SelectFilter::make('event_type') + ->label(__('panel-admin::discord.event_logs.filters.event_type')) + ->options(fn (): array => DiscordEventLog::query() + ->select('event_type') + ->distinct() + ->orderBy('event_type') + ->pluck('event_type') + ->mapWithKeys(fn (string $type): array => [$type => DiscordEventType::tryFrom($type)?->getLabel() ?? $type]) + ->all()) + ->multiple() + ->searchable(), + + Filter::make('created_at') + ->label(__('panel-admin::discord.event_logs.filters.period.label')) + ->schema([ + DatePicker::make('from') + ->label(__('panel-admin::discord.event_logs.filters.period.from')), + DatePicker::make('until') + ->label(__('panel-admin::discord.event_logs.filters.period.until')), + ]) + ->query(function (Builder $query, array $data): Builder { + $from = $data['from'] ?? null; + $until = $data['until'] ?? null; + + if (is_string($from) && filled($from)) { + $query->where('created_at', '>=', $from); + } + + if (is_string($until) && filled($until)) { + $query->where('created_at', '<=', $until.' 23:59:59'); + } + + return $query; + }), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/DiscordGuildResource.php b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/DiscordGuildResource.php new file mode 100644 index 000000000..dd3969abd --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/DiscordGuildResource.php @@ -0,0 +1,94 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListDiscordGuilds::route('/'), + 'view' => ViewDiscordGuild::route('/{record}'), + ]; + } + + public static function getGloballySearchableAttributes(): array + { + return ['name']; + } + + /** + * @return array + */ + public static function getRelations(): array + { + return [ + ChannelsRelationManager::class, + RolesRelationManager::class, + MembersRelationManager::class, + ]; + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Pages/ListDiscordGuilds.php b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Pages/ListDiscordGuilds.php new file mode 100644 index 000000000..cc9bea42b --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Pages/ListDiscordGuilds.php @@ -0,0 +1,13 @@ +recordTitleAttribute('name') + ->defaultSort('position') + ->columns([ + TextColumn::make('name') + ->label(__('panel-admin::discord.channels.fields.name')), + + TextColumn::make('type') + ->label(__('panel-admin::discord.channels.fields.type')) + ->badge(), + + TextColumn::make('position') + ->label(__('panel-admin::discord.channels.fields.position')) + ->numeric() + ->sortable(), + + IconColumn::make('nsfw') + ->label(__('panel-admin::discord.channels.fields.nsfw')) + ->boolean(), + ]) + ->recordActions([ + ViewAction::make() + ->url(fn (DiscordChannel $record): string => DiscordChannelResource::getUrl('view', ['record' => $record])), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/MembersRelationManager.php b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/MembersRelationManager.php new file mode 100644 index 000000000..ed0640f3f --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/MembersRelationManager.php @@ -0,0 +1,51 @@ +recordTitleAttribute('username') + ->deferLoading() + ->defaultSort('joined_at', 'desc') + ->modifyQueryUsing(fn (Builder $query): Builder => $query->whereNull('left_at')) + ->columns([ + ImageColumn::make('avatar') + ->label(__('panel-admin::discord.members.fields.avatar')) + ->circular() + ->state(fn (DiscordMember $record): ?string => $record->avatar + ? sprintf('https://cdn.discordapp.com/avatars/%s/%s.png?size=64', $record->discord_user_id, $record->avatar) + : null) + ->defaultImageUrl(fn (DiscordMember $record): string => sprintf('https://cdn.discordapp.com/embed/avatars/%d.png', ((int) $record->discord_user_id >> 22) % 6)), + + TextColumn::make('username') + ->label(__('panel-admin::discord.members.fields.username')) + ->searchable(), + + TextColumn::make('joined_at') + ->label(__('panel-admin::discord.members.fields.joined_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable(), + ]) + ->recordActions([ + ViewAction::make() + ->url(fn (DiscordMember $record): string => DiscordMemberResource::getUrl('view', ['record' => $record])), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/RolesRelationManager.php b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/RolesRelationManager.php new file mode 100644 index 000000000..100a636af --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/RelationManagers/RolesRelationManager.php @@ -0,0 +1,48 @@ +recordTitleAttribute('name') + ->defaultSort('position', 'desc') + ->columns([ + ColorColumn::make('color') + ->label(__('panel-admin::discord.roles.fields.color')) + ->state(fn (DiscordRole $record): ?string => $record->color > 0 + ? sprintf('#%06X', $record->color) + : null), + + TextColumn::make('name') + ->label(__('panel-admin::discord.roles.fields.name')), + + TextColumn::make('position') + ->label(__('panel-admin::discord.roles.fields.position')) + ->sortable(), + + IconColumn::make('is_managed') + ->label(__('panel-admin::discord.roles.fields.is_managed')) + ->boolean(), + ]) + ->recordActions([ + ViewAction::make() + ->url(fn (DiscordRole $record): string => DiscordRoleResource::getUrl('view', ['record' => $record])), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Schemas/DiscordGuildInfolist.php b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Schemas/DiscordGuildInfolist.php new file mode 100644 index 000000000..ce7acc75d --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Schemas/DiscordGuildInfolist.php @@ -0,0 +1,75 @@ +columns(1) + ->components([ + Section::make(__('panel-admin::discord.guilds.sections.overview')) + ->columns(2) + ->schema([ + ImageEntry::make('icon') + ->label(__('panel-admin::discord.guilds.fields.icon')) + ->circular() + ->state(fn (DiscordGuild $record): ?string => $record->icon + ? sprintf('https://cdn.discordapp.com/icons/%s/%s.png?size=128', $record->discord_guild_id, $record->icon) + : null), + + TextEntry::make('name') + ->label(__('panel-admin::discord.guilds.fields.name')) + ->weight(FontWeight::Bold), + + TextEntry::make('discord_guild_id') + ->label(__('panel-admin::discord.guilds.fields.discord_guild_id')) + ->copyable(), + + TextEntry::make('description') + ->label(__('panel-admin::discord.guilds.fields.description')) + ->columnSpanFull() + ->placeholder('—'), + + TextEntry::make('member_count') + ->label(__('panel-admin::discord.guilds.fields.member_count')) + ->numeric(), + + TextEntry::make('premium_tier') + ->label(__('panel-admin::discord.guilds.fields.premium_tier')) + ->badge() + ->formatStateUsing(fn (int $state): string => 'Tier '.$state) + ->color(fn (int $state): string => match (true) { + $state >= 3 => 'success', + $state >= 1 => 'warning', + default => 'gray', + }), + + TextEntry::make('synced_at') + ->label(__('panel-admin::discord.guilds.fields.synced_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + ]), + + Section::make(__('panel-admin::discord.guilds.sections.features')) + ->collapsed() + ->schema([ + TextEntry::make('features') + ->label(__('panel-admin::discord.guilds.fields.features')) + ->badge() + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Tables/DiscordGuildsTable.php b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Tables/DiscordGuildsTable.php new file mode 100644 index 000000000..db11e349d --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordGuilds/Tables/DiscordGuildsTable.php @@ -0,0 +1,79 @@ +defaultSort('name') + ->columns([ + ImageColumn::make('icon') + ->label(__('panel-admin::discord.guilds.fields.icon')) + ->circular() + ->state(fn (DiscordGuild $record): ?string => $record->icon + ? sprintf('https://cdn.discordapp.com/icons/%s/%s.png?size=64', $record->discord_guild_id, $record->icon) + : null), + + TextColumn::make('name') + ->label(__('panel-admin::discord.guilds.fields.name')) + ->searchable() + ->sortable() + ->weight(FontWeight::Bold) + ->description(fn (DiscordGuild $record): ?string => $record->description), + + TextColumn::make('member_count') + ->label(__('panel-admin::discord.guilds.fields.member_count')) + ->numeric() + ->sortable(), + + TextColumn::make('premium_tier') + ->label(__('panel-admin::discord.guilds.fields.premium_tier')) + ->badge() + ->formatStateUsing(fn (int $state): string => 'Tier '.$state) + ->color(fn (int $state): string => match (true) { + $state >= 3 => 'success', + $state >= 1 => 'warning', + default => 'gray', + }), + + TextColumn::make('channels_count') + ->label(__('panel-admin::discord.guilds.fields.channels_count')) + ->counts('channels') + ->numeric(), + + TextColumn::make('roles_count') + ->label(__('panel-admin::discord.guilds.fields.roles_count')) + ->counts('roles') + ->numeric(), + + TextColumn::make('synced_at') + ->label(__('panel-admin::discord.guilds.fields.synced_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable() + ->placeholder('—'), + + TextColumn::make('discord_guild_id') + ->label(__('panel-admin::discord.guilds.fields.discord_guild_id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/DiscordMemberResource.php b/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/DiscordMemberResource.php new file mode 100644 index 000000000..d233b4175 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/DiscordMemberResource.php @@ -0,0 +1,98 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListDiscordMembers::route('/'), + 'view' => ViewDiscordMember::route('/{record}'), + ]; + } + + public static function getGloballySearchableAttributes(): array + { + return ['username', 'global_name', 'nickname']; + } + + /** + * @param DiscordMember $record + */ + public static function getGlobalSearchResultDetails(Model $record): array + { + $details = []; + + if ($record->global_name) { + $details[__('panel-admin::discord.members.fields.global_name')] = $record->global_name; + } + + if ($record->nickname) { + $details[__('panel-admin::discord.members.fields.nickname')] = $record->nickname; + } + + return $details; + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/Pages/ListDiscordMembers.php b/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/Pages/ListDiscordMembers.php new file mode 100644 index 000000000..d52a68b37 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/Pages/ListDiscordMembers.php @@ -0,0 +1,13 @@ +columns(1) + ->components([ + Section::make(__('panel-admin::discord.members.sections.profile')) + ->columns(2) + ->schema([ + ImageEntry::make('avatar') + ->label(__('panel-admin::discord.members.fields.avatar')) + ->circular() + ->state(fn (DiscordMember $record): ?string => $record->avatar + ? sprintf('https://cdn.discordapp.com/avatars/%s/%s.png?size=128', $record->discord_user_id, $record->avatar) + : null) + ->defaultImageUrl(fn (DiscordMember $record): string => sprintf('https://cdn.discordapp.com/embed/avatars/%d.png', ((int) $record->discord_user_id >> 22) % 6)), + + TextEntry::make('username') + ->label(__('panel-admin::discord.members.fields.username')) + ->weight(FontWeight::Bold), + + TextEntry::make('global_name') + ->label(__('panel-admin::discord.members.fields.global_name')) + ->placeholder('—'), + + TextEntry::make('nickname') + ->label(__('panel-admin::discord.members.fields.nickname')) + ->placeholder('—'), + + TextEntry::make('discord_user_id') + ->label(__('panel-admin::discord.members.fields.discord_user_id')) + ->copyable(), + + TextEntry::make('guild.name') + ->label(__('panel-admin::discord.members.fields.guild')) + ->url(fn (DiscordMember $record): string => DiscordGuildResource::getUrl('view', ['record' => $record->guild])) + ->color('primary'), + ]), + + Section::make(__('panel-admin::discord.members.sections.status')) + ->columns(2) + ->schema([ + IconEntry::make('is_bot') + ->label(__('panel-admin::discord.members.fields.is_bot')) + ->boolean(), + + IconEntry::make('is_pending') + ->label(__('panel-admin::discord.members.fields.is_pending')) + ->boolean(), + + TextEntry::make('joined_at') + ->label(__('panel-admin::discord.members.fields.joined_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')), + + TextEntry::make('premium_since') + ->label(__('panel-admin::discord.members.fields.premium_since')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + + TextEntry::make('communication_disabled_until') + ->label(__('panel-admin::discord.members.fields.communication_disabled_until')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + + TextEntry::make('left_at') + ->label(__('panel-admin::discord.members.fields.left_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + ]), + + Section::make(__('panel-admin::discord.members.sections.roles')) + ->schema([ + TextEntry::make('roles.name') + ->label(__('panel-admin::discord.members.fields.roles')) + ->badge() + ->columnSpanFull() + ->placeholder('—'), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/Tables/DiscordMembersTable.php b/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/Tables/DiscordMembersTable.php new file mode 100644 index 000000000..f1ad84c5d --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordMembers/Tables/DiscordMembersTable.php @@ -0,0 +1,109 @@ +deferLoading() + ->defaultSort('joined_at', 'desc') + // Cursor pagination (padrão global) quebra ao ordenar pelo agregado + // roles_count na página seguinte; length-aware suporta. + ->paginationMode(PaginationMode::Default) + ->paginated([25, 50, 100]) + ->columns([ + ImageColumn::make('avatar') + ->label(__('panel-admin::discord.members.fields.avatar')) + ->circular() + ->state(fn (DiscordMember $record): ?string => $record->avatar + ? sprintf('https://cdn.discordapp.com/avatars/%s/%s.png?size=64', $record->discord_user_id, $record->avatar) + : null) + ->defaultImageUrl(fn (DiscordMember $record): string => sprintf('https://cdn.discordapp.com/embed/avatars/%d.png', ((int) $record->discord_user_id >> 22) % 6)), + + TextColumn::make('username') + ->label(__('panel-admin::discord.members.fields.username')) + ->searchable() + ->sortable() + ->weight(FontWeight::Medium) + ->description(fn (DiscordMember $record): ?string => $record->nickname ?? $record->global_name), + + TextColumn::make('joined_at') + ->label(__('panel-admin::discord.members.fields.joined_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable(), + + TextColumn::make('roles_count') + ->label(__('panel-admin::discord.members.fields.roles_count')) + ->counts('roles') + ->numeric() + ->sortable(), + + IconColumn::make('is_bot') + ->label(__('panel-admin::discord.members.fields.is_bot')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('premium_since') + ->label(__('panel-admin::discord.members.fields.premium_since')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('left_at') + ->label(__('panel-admin::discord.members.fields.left_at')) + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('discord_user_id') + ->label(__('panel-admin::discord.members.fields.discord_user_id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('left_at') + ->label(__('panel-admin::discord.members.filters.left.label')) + ->nullable() + ->default(state: false) + ->trueLabel(__('panel-admin::discord.members.filters.left.true')) + ->falseLabel(__('panel-admin::discord.members.filters.left.false')) + ->placeholder(__('panel-admin::discord.members.filters.left.placeholder')), + + TernaryFilter::make('is_bot') + ->label(__('panel-admin::discord.members.filters.is_bot')), + + TernaryFilter::make('is_pending') + ->label(__('panel-admin::discord.members.filters.is_pending')), + + SelectFilter::make('roles') + ->label(__('panel-admin::discord.members.filters.roles')) + ->relationship('roles', 'name') + ->multiple() + ->preload() + ->searchable(), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/DiscordRoleResource.php b/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/DiscordRoleResource.php new file mode 100644 index 000000000..1afc5d376 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/DiscordRoleResource.php @@ -0,0 +1,103 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListDiscordRoles::route('/'), + 'view' => ViewDiscordRole::route('/{record}'), + ]; + } + + /** + * @return Builder + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + /** @var Builder $query */ + $query = parent::getGlobalSearchEloquentQuery()->with(['guild']); + + return $query; + } + + public static function getGloballySearchableAttributes(): array + { + return ['name', 'guild.name']; + } + + /** + * @param DiscordRole $record + * @return array + */ + public static function getGlobalSearchResultDetails(Model $record): array + { + return [ + 'Guild' => $record->guild->name, + ]; + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/Pages/ListDiscordRoles.php b/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/Pages/ListDiscordRoles.php new file mode 100644 index 000000000..54c71100d --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/Pages/ListDiscordRoles.php @@ -0,0 +1,13 @@ +columns(1) + ->components([ + Section::make(__('panel-admin::discord.roles.sections.role')) + ->columns(2) + ->schema([ + ColorEntry::make('color') + ->label(__('panel-admin::discord.roles.fields.color')) + ->state(fn (DiscordRole $record): ?string => $record->color > 0 + ? sprintf('#%06X', $record->color) + : null) + ->placeholder('—'), + + TextEntry::make('name') + ->label(__('panel-admin::discord.roles.fields.name')) + ->weight(FontWeight::Bold), + + TextEntry::make('position') + ->label(__('panel-admin::discord.roles.fields.position')) + ->numeric(), + + TextEntry::make('members_count') + ->label(__('panel-admin::discord.roles.fields.members_count')) + ->state(fn (DiscordRole $record): int => $record->members()->count()), + + TextEntry::make('permissions') + ->label(__('panel-admin::discord.roles.fields.permissions')) + ->copyable() + ->helperText(__('panel-admin::discord.roles.fields.permissions_helper')), + + TextEntry::make('discord_role_id') + ->label(__('panel-admin::discord.roles.fields.discord_role_id')) + ->copyable(), + + IconEntry::make('is_hoisted') + ->label(__('panel-admin::discord.roles.fields.is_hoisted')) + ->boolean(), + + IconEntry::make('is_mentionable') + ->label(__('panel-admin::discord.roles.fields.is_mentionable')) + ->boolean(), + + IconEntry::make('is_managed') + ->label(__('panel-admin::discord.roles.fields.is_managed')) + ->boolean(), + + TextEntry::make('guild.name') + ->label(__('panel-admin::discord.roles.fields.guild')) + ->url(fn (DiscordRole $record): string => DiscordGuildResource::getUrl('view', ['record' => $record->guild])) + ->color('primary'), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/Tables/DiscordRolesTable.php b/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/Tables/DiscordRolesTable.php new file mode 100644 index 000000000..0dd5f338b --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Resources/DiscordRoles/Tables/DiscordRolesTable.php @@ -0,0 +1,80 @@ +paginationMode(PaginationMode::Default) + ->columns([ + ColorColumn::make('color') + ->label(__('panel-admin::discord.roles.fields.color')) + ->state(fn (DiscordRole $record): ?string => $record->color > 0 + ? sprintf('#%06X', $record->color) + : null), + + TextColumn::make('name') + ->label(__('panel-admin::discord.roles.fields.name')) + ->searchable() + ->sortable() + ->weight(FontWeight::Medium), + + TextColumn::make('position') + ->label(__('panel-admin::discord.roles.fields.position')) + ->numeric() + ->sortable(), + + TextColumn::make('members_count') + ->label(__('panel-admin::discord.roles.fields.members_count')) + ->counts('members') + ->numeric() + ->sortable(), + + IconColumn::make('is_hoisted') + ->label(__('panel-admin::discord.roles.fields.is_hoisted')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('is_mentionable') + ->label(__('panel-admin::discord.roles.fields.is_mentionable')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('is_managed') + ->label(__('panel-admin::discord.roles.fields.is_managed')) + ->boolean(), + + TextColumn::make('discord_role_id') + ->label(__('panel-admin::discord.roles.fields.discord_role_id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_managed') + ->label(__('panel-admin::discord.roles.filters.is_managed')), + + TernaryFilter::make('is_hoisted') + ->label(__('panel-admin::discord.roles.filters.is_hoisted')), + ]) + ->defaultSort('position', 'desc') + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Discord/Widgets/DiscordStatsWidget.php b/app-modules/panel-admin/src/Discord/Widgets/DiscordStatsWidget.php new file mode 100644 index 000000000..9986fafd9 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Widgets/DiscordStatsWidget.php @@ -0,0 +1,80 @@ +whereNull('left_at') + ->where('is_bot', operator: false) + ->count(); + + $joins7d = DiscordMember::query() + ->where('joined_at', '>=', now()->subDays(7)) + ->count(); + + $leaves7d = DiscordMember::query() + ->where('left_at', '>=', now()->subDays(7)) + ->count(); + + $events24h = DiscordEventLog::query() + ->where('created_at', '>=', now()->subDay()) + ->count(); + + $boosters = DiscordMember::query() + ->whereNotNull('premium_since') + ->whereNull('left_at') + ->count(); + + $channels = DiscordChannel::query() + ->where('type', '!=', DiscordChannelType::GuildCategory) + ->count(); + + return [ + Stat::make(__('panel-admin::discord.dashboard.stats.active_members'), $activeMembers) + ->description(__('panel-admin::discord.dashboard.stats.active_members_desc')) + ->icon(Heroicon::OutlinedUsers) + ->color(Color::Blue), + Stat::make(__('panel-admin::discord.dashboard.stats.joins_7d'), $joins7d) + ->description(__('panel-admin::discord.dashboard.stats.joins_7d_desc')) + ->icon(Heroicon::OutlinedArrowRightEndOnRectangle) + ->color(Color::Green), + Stat::make(__('panel-admin::discord.dashboard.stats.leaves_7d'), $leaves7d) + ->description(__('panel-admin::discord.dashboard.stats.leaves_7d_desc')) + ->icon(Heroicon::OutlinedArrowLeftStartOnRectangle) + ->color(Color::Red), + Stat::make(__('panel-admin::discord.dashboard.stats.events_24h'), $events24h) + ->description(__('panel-admin::discord.dashboard.stats.events_24h_desc')) + ->icon(Heroicon::OutlinedBolt) + ->color(Color::Amber), + Stat::make(__('panel-admin::discord.dashboard.stats.boosters'), $boosters) + ->description(__('panel-admin::discord.dashboard.stats.boosters_desc')) + ->icon(Heroicon::OutlinedSparkles) + ->color(Color::Fuchsia), + Stat::make(__('panel-admin::discord.dashboard.stats.channels'), $channels) + ->description(__('panel-admin::discord.dashboard.stats.channels_desc')) + ->icon(Heroicon::OutlinedHashtag) + ->color(Color::Gray), + ]; + } +} diff --git a/app-modules/panel-admin/src/Discord/Widgets/EventsPerDayChartWidget.php b/app-modules/panel-admin/src/Discord/Widgets/EventsPerDayChartWidget.php new file mode 100644 index 000000000..33c9d8737 --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Widgets/EventsPerDayChartWidget.php @@ -0,0 +1,61 @@ +subDays(29)->startOfDay(); + + $events = DiscordEventLog::query() + ->where('created_at', '>=', $start) + ->selectRaw('DATE(created_at) as date, COUNT(*) as total') + ->groupBy('date') + ->orderBy('date') + ->pluck('total', 'date'); + + $labels = []; + $data = []; + + for ($day = 0; $day < 30; $day++) { + $date = $start->copy()->addDays($day); + $key = $date->toDateString(); + $labels[] = $date->format('M d'); + $data[] = $events->get($key, 0); + } + + return [ + 'datasets' => [ + [ + 'label' => __('panel-admin::discord.dashboard.events_per_day_label'), + 'data' => $data, + 'borderColor' => '#782bf1', + 'backgroundColor' => 'rgba(120, 43, 241, 0.1)', + 'fill' => true, + 'tension' => 0.3, + ], + ], + 'labels' => $labels, + ]; + } +} diff --git a/app-modules/panel-admin/src/Discord/Widgets/MemberGrowthChartWidget.php b/app-modules/panel-admin/src/Discord/Widgets/MemberGrowthChartWidget.php new file mode 100644 index 000000000..353e9550c --- /dev/null +++ b/app-modules/panel-admin/src/Discord/Widgets/MemberGrowthChartWidget.php @@ -0,0 +1,85 @@ +subDays(29)->startOfDay(); + + $joins = DiscordMember::query() + ->where('joined_at', '>=', $start) + ->selectRaw('DATE(joined_at) as date, COUNT(*) as total') + ->groupBy('date') + ->pluck('total', 'date'); + + $leaves = DiscordMember::query() + ->where('left_at', '>=', $start) + ->selectRaw('DATE(left_at) as date, COUNT(*) as total') + ->groupBy('date') + ->pluck('total', 'date'); + + $labels = []; + $joinsData = []; + $leavesData = []; + + for ($day = 0; $day < 30; $day++) { + $date = $start->copy()->addDays($day); + $key = $date->toDateString(); + $labels[] = $date->format('M d'); + $joinsData[] = $joins->get($key, 0); + $leavesData[] = $leaves->get($key, 0) * -1; + } + + return [ + 'datasets' => [ + [ + 'label' => __('panel-admin::discord.dashboard.member_growth.joins'), + 'data' => $joinsData, + 'backgroundColor' => 'rgba(34, 197, 94, 0.7)', + ], + [ + 'label' => __('panel-admin::discord.dashboard.member_growth.leaves'), + 'data' => $leavesData, + 'backgroundColor' => 'rgba(239, 68, 68, 0.7)', + ], + ], + 'labels' => $labels, + ]; + } + + protected function getOptions(): array|RawJs|null + { + return [ + 'scales' => [ + 'x' => [ + 'stacked' => true, + ], + 'y' => [ + 'stacked' => true, + ], + ], + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php index f8e01731b..f84c45d02 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php @@ -23,7 +23,6 @@ use He4rt\Events\Event\Models\Event; use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; -use Illuminate\Validation\Rules\Unique; final class EventForm { @@ -46,13 +45,6 @@ public static function configure(Schema $schema): Schema table: Event::class, column: 'slug', ignoreRecord: true, - modifyRuleUsing: static function (Unique $rule, Get $get): Unique { - $tenantId = $get('tenant_id'); - - return filled($tenantId) - ? $rule->where('tenant_id', $tenantId) - : $rule->whereNull('tenant_id'); - }, ), Select::make('event_type') @@ -60,12 +52,6 @@ public static function configure(Schema $schema): Schema ->options(EventType::class) ->required(), - Select::make('tenant_id') - ->label(__('panel-admin::events.columns.tenant')) - ->relationship('tenant', 'name') - ->searchable() - ->nullable(), - TextInput::make('location') ->label(__('panel-admin::events.columns.location')) ->nullable(), diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php index 23e7e7d21..10f01538b 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php @@ -27,9 +27,6 @@ public static function configure(Schema $schema): Schema ->label(__('panel-admin::events.columns.type')) ->badge(), - TextEntry::make('tenant.name') - ->label(__('panel-admin::events.columns.tenant')), - TextEntry::make('location') ->label(__('panel-admin::events.columns.location')), diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php index 827ba8d06..39b75472b 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php @@ -30,11 +30,6 @@ public static function table(Table $table): Table ->badge() ->sortable(), - TextColumn::make('tenant.name') - ->label(__('panel-admin::events.columns.tenant')) - ->searchable() - ->sortable(), - TextColumn::make('starts_at') ->label(__('panel-admin::events.columns.starts_at')) ->dateTime() diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/RelationManagers/MessagesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/RelationManagers/MessagesRelationManager.php index d390c2225..712548310 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/RelationManagers/MessagesRelationManager.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/RelationManagers/MessagesRelationManager.php @@ -54,12 +54,6 @@ public function form(Schema $schema): Schema DatePicker::make('sent_at') ->label('Sent Date'), - Select::make('tenant_id') - ->label('Tenant Id') - ->relationship('tenant', 'name') - ->searchable() - ->required(), - TextInput::make('reactions_count') ->label('Reactions Count') ->required() @@ -272,7 +266,6 @@ public function table(Table $table): Table 'author_external_id' => $identity->external_account_id ?? '', 'text' => $record->content, 'media_urls' => [], - 'tenant_id' => (string) $record->tenant_id, 'metadata' => [ 'channel_id' => $record->channel_id, ], diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php index ee1e28c8c..72b7fda22 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php @@ -16,10 +16,6 @@ public static function configure(Schema $schema): Schema { return $schema ->components([ - TextInput::make('tenant_id') - ->label('Tenant Id') - ->required(), - TextInput::make('model_type') ->label('Model Type') ->required(), diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php index e992e55ca..8fed4232b 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php @@ -16,9 +16,6 @@ public static function configure(Schema $schema): Schema TextEntry::make('id') ->label('Id'), - TextEntry::make('tenant_id') - ->label('Tenant Id'), - TextEntry::make('model_type') ->label('Model Type'), diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php index 1099d79bc..02ca36048 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php @@ -23,9 +23,6 @@ public static function table(Table $table): Table { return $table ->columns([ - TextColumn::make('tenant_id') - ->label('Tenant Id'), - TextColumn::make('model_type') ->label('Model Type'), diff --git a/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php b/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php index 63629059b..014bec089 100644 --- a/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php +++ b/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php @@ -7,7 +7,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\EditAction; -use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; @@ -24,7 +23,6 @@ use He4rt\PanelAdmin\Github\Resources\GithubRepositoryResource\Pages\CreateGithubRepository; use He4rt\PanelAdmin\Github\Resources\GithubRepositoryResource\Pages\EditGithubRepository; use He4rt\PanelAdmin\Github\Resources\GithubRepositoryResource\Pages\ListGithubRepositories; -use Illuminate\Validation\Rules\Unique; class GithubRepositoryResource extends Resource { @@ -62,9 +60,7 @@ public static function form(Schema $schema): Schema ->required() ->maxLength(255) ->rule('regex:/^[\w.-]+\/[\w.-]+$/') - // Unicidade é por tenant: o mesmo repo pode ser acompanhado por - // mais de uma comunidade, então a validação respeita o tenant atual. - ->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule): Unique => $rule->where('tenant_id', Filament::getTenant()?->getKey())), + ->unique(ignoreRecord: true), Toggle::make('enabled') ->label('Habilitado') ->default(state: true), diff --git a/app-modules/panel-admin/src/Http/Middleware/ApplyTenantScopes.php b/app-modules/panel-admin/src/Http/Middleware/ApplyTenantScopes.php deleted file mode 100644 index ed270f022..000000000 --- a/app-modules/panel-admin/src/Http/Middleware/ApplyTenantScopes.php +++ /dev/null @@ -1,39 +0,0 @@ -> $models */ - $models = config('panel-admin.tenant_scoped_models', []); - - foreach ($models as $model) { - $model::addGlobalScope( - 'tenant', - fn (Builder $query) => $query->whereBelongsTo($tenant), - ); - } - - return $next($request); - } -} diff --git a/app-modules/panel-admin/src/Marketing/Pages/Location/Data/CommunityActivitySnapshot.php b/app-modules/panel-admin/src/Marketing/Pages/Location/Data/CommunityActivitySnapshot.php new file mode 100644 index 000000000..7246179c0 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Pages/Location/Data/CommunityActivitySnapshot.php @@ -0,0 +1,49 @@ +pctDiff($this->webActive, $this->webActivePrev); + } + + public function discordTrend(): float + { + return $this->pctDiff($this->discordActive, $this->discordActivePrev); + } + + public function coverage(): int + { + return $this->totalMembers > 0 + ? (int) round($this->locatedMembers / $this->totalMembers * 100) + : 0; + } + + private function pctDiff(int $current, int $previous): float + { + if ($previous === 0) { + return 0.0; + } + + return round((($current - $previous) / $previous) * 100, 1); + } +} diff --git a/app-modules/panel-admin/src/Marketing/Pages/Location/Data/MemberDistribution.php b/app-modules/panel-admin/src/Marketing/Pages/Location/Data/MemberDistribution.php new file mode 100644 index 000000000..1f247015c --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Pages/Location/Data/MemberDistribution.php @@ -0,0 +1,23 @@ + $byName members keyed by accent-insensitive state name (map join key) + * @param list $top the five states with the most members + */ + public function __construct( + public int $total, + public int $statesReached, + public int $statesTotal, + public array $byName, + public array $top, + ) {} +} diff --git a/app-modules/panel-admin/src/Marketing/Pages/Location/Data/StateShare.php b/app-modules/panel-admin/src/Marketing/Pages/Location/Data/StateShare.php new file mode 100644 index 000000000..c1575cbd3 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Pages/Location/Data/StateShare.php @@ -0,0 +1,17 @@ + 1, 'lg' => 5]; + } + + /** + * @return array + */ + protected function getHeaderWidgets(): array + { + return [ + LocationStatsWidget::class, + LocationMapWidget::class, + TopStatesWidget::class, + ]; + } +} diff --git a/app-modules/panel-admin/src/Marketing/Pages/Location/Queries/CommunityActivityStats.php b/app-modules/panel-admin/src/Marketing/Pages/Location/Queries/CommunityActivityStats.php new file mode 100644 index 000000000..0d594d01f --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Pages/Location/Queries/CommunityActivityStats.php @@ -0,0 +1,89 @@ +windows(); + + $byState = new MembersByState()->get(); + + return new CommunityActivitySnapshot( + webActive: $this->distinctTimelineUsers($currentStart, $currentEnd), + webActivePrev: $this->distinctTimelineUsers($prevStart, $prevEnd), + discordActive: $this->distinctMessageIdentities($currentStart, $currentEnd), + discordActivePrev: $this->distinctMessageIdentities($prevStart, $prevEnd), + locatedMembers: $this->locatedMembers(), + totalMembers: $this->totalMembers(), + statesReached: $byState->statesReached, + statesTotal: $byState->statesTotal, + ); + }); + } + + /** + * @return array{0: CarbonInterface, 1: CarbonInterface, 2: CarbonInterface, 3: CarbonInterface} + */ + private function windows(): array + { + $now = Date::now(config('app.display_timezone')); + + return [ + $now->copy()->subDays($this->rangeDays)->utc(), + $now->copy()->utc(), + $now->copy()->subDays($this->rangeDays * 2)->utc(), + $now->copy()->subDays($this->rangeDays)->utc(), + ]; + } + + private function distinctTimelineUsers(CarbonInterface $start, CarbonInterface $end): int + { + return (int) DB::table('activity_timeline') + ->whereBetween('created_at', [$start, $end]) + ->distinct() + ->count('user_id'); + } + + private function distinctMessageIdentities(CarbonInterface $start, CarbonInterface $end): int + { + return (int) DB::table('messages') + ->whereNotNull('sent_at') + ->whereBetween('sent_at', [$start, $end]) + ->distinct() + ->count('external_identity_id'); + } + + private function locatedMembers(): int + { + return (int) DB::table('addresses') + ->where('addressable_type', 'user') + ->whereNotNull('state') + ->where('state', '!=', '') + ->distinct() + ->count('addressable_id'); + } + + private function totalMembers(): int + { + return (int) DB::table('users')->count(); + } +} diff --git a/app-modules/panel-admin/src/Marketing/Pages/Location/Queries/MembersByState.php b/app-modules/panel-admin/src/Marketing/Pages/Location/Queries/MembersByState.php new file mode 100644 index 000000000..d03cac641 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Pages/Location/Queries/MembersByState.php @@ -0,0 +1,80 @@ +normalize($name)] = $name; + } + + /** @var Collection $rows */ + $rows = DB::table('addresses') + ->where('addressable_type', 'user') + ->whereNotNull('state') + ->where('state', '!=', '') + ->select('state', DB::raw('COUNT(*) AS members')) + ->groupBy('state') + ->get(); + + $counts = []; + foreach ($rows as $row) { + $key = $this->normalize((string) $row->state); + + if (!isset($canonical[$key])) { + continue; + } + + $counts[$key] = ($counts[$key] ?? 0) + (int) $row->members; + } + + arsort($counts); + + $total = array_sum($counts); + + $top = []; + foreach (array_slice($counts, 0, 5, preserve_keys: true) as $key => $members) { + $top[] = new StateShare( + name: $canonical[$key], + members: $members, + share: $total > 0 ? round($members / $total * 100, 1) : 0.0, + ); + } + + return new MemberDistribution( + total: $total, + statesReached: count($counts), + statesTotal: count($canonical), + byName: $counts, + top: $top, + ); + }); + } + + private function normalize(string $name): string + { + return Str::of($name)->ascii()->lower()->squish()->value(); + } +} diff --git a/app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php b/app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php index e9869d625..354c7b1bb 100644 --- a/app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php +++ b/app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php @@ -58,7 +58,7 @@ public function loadParticipants(): void $tz = config('app.display_timezone'); $start = Date::parse($this->startDate, $tz)->utc(); - $end = Date::parse($this->endDate, $tz)->utc(); + $end = Date::parse($this->endDate, $tz)->endOfMinute()->utc(); /** @var Collection $messageStats */ $messageStats = Message::query() @@ -73,6 +73,8 @@ public function loadParticipants(): void $identityIds = $messageStats->pluck('external_identity_id'); $identities = ExternalIdentity::query() + ->withTrashed() + ->with('user') ->whereIn('id', $identityIds) ->get() ->keyBy('id'); @@ -102,25 +104,18 @@ private function extractDiscordData(?ExternalIdentity $identity, int $totalMessa } $metadata = $identity->metadata ?? []; + $discordUser = $metadata['user'] ?? $metadata['author'] ?? []; - $username = $metadata['username'] ?? null; - $avatar = $metadata['avatar'] ?? null; - $globalName = $metadata['global_name'] ?? null; - - if (isset($metadata['user'])) { - $discordUser = $metadata['user']; - $username ??= $discordUser['username'] ?? null; - $globalName ??= $discordUser['global_name'] ?? null; - - if (!$avatar && isset($discordUser['avatar'])) { - $avatar = sprintf( - 'https://cdn.discordapp.com/avatars/%s/%s.png?size=128', - $identity->external_account_id, - $discordUser['avatar'], - ); - } + if (!is_array($discordUser)) { + $discordUser = []; } + $linkedUser = $identity->user; + + $username = $metadata['username'] ?? $discordUser['username'] ?? $linkedUser?->username; + $avatar = $metadata['avatar'] ?? $discordUser['avatar'] ?? null; + $globalName = $metadata['global_name'] ?? $discordUser['global_name'] ?? $linkedUser?->name; + if ($avatar && !str_starts_with((string) $avatar, 'http')) { $avatar = sprintf( 'https://cdn.discordapp.com/avatars/%s/%s.png?size=128', diff --git a/app-modules/panel-admin/src/Marketing/Support/BrazilStatesGeometry.php b/app-modules/panel-admin/src/Marketing/Support/BrazilStatesGeometry.php new file mode 100644 index 000000000..b0c326330 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Support/BrazilStatesGeometry.php @@ -0,0 +1,38 @@ + + */ + public static function get(): array + { + return Cache::remember('panel-admin.geo.br-states', now()->addDays(30), static function (): array { + $path = base_path('app-modules/panel-admin/resources/geo/br-states.geojson'); + + /** @var array $geojson */ + $geojson = json_decode(File::get($path), associative: true, flags: JSON_THROW_ON_ERROR); + + return $geojson; + }); + } +} diff --git a/app-modules/panel-admin/src/Marketing/Widgets/LocationMapWidget.php b/app-modules/panel-admin/src/Marketing/Widgets/LocationMapWidget.php new file mode 100644 index 000000000..dd0567539 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Widgets/LocationMapWidget.php @@ -0,0 +1,32 @@ + + */ + protected function getViewData(): array + { + $members = new MembersByState()->get(); + + return [ + 'byName' => $members->byName, + 'total' => $members->total, + 'geometry' => BrazilStatesGeometry::get(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Marketing/Widgets/LocationStatsWidget.php b/app-modules/panel-admin/src/Marketing/Widgets/LocationStatsWidget.php new file mode 100644 index 000000000..7cee09e11 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Widgets/LocationStatsWidget.php @@ -0,0 +1,53 @@ +get(); + + $webTrend = $stats->webTrend(); + $discordTrend = $stats->discordTrend(); + + return [ + Stat::make(__('panel-admin::location.stats.web_active'), number_format($stats->webActive)) + ->description(abs($webTrend).__('panel-admin::location.stats.vs_previous')) + ->descriptionIcon($webTrend >= 0 ? Heroicon::ArrowTrendingUp : Heroicon::ArrowTrendingDown) + ->color($webTrend >= 0 ? Color::Purple : Color::Red), + + Stat::make(__('panel-admin::location.stats.discord_active'), number_format($stats->discordActive)) + ->description(abs($discordTrend).__('panel-admin::location.stats.vs_previous')) + ->descriptionIcon($discordTrend >= 0 ? Heroicon::ArrowTrendingUp : Heroicon::ArrowTrendingDown) + ->color($discordTrend >= 0 ? Color::Green : Color::Red), + + Stat::make(__('panel-admin::location.stats.located'), number_format($stats->locatedMembers)) + ->description(__('panel-admin::location.stats.coverage', ['percent' => $stats->coverage()])) + ->descriptionIcon(Heroicon::OutlinedMapPin) + ->color(Color::Amber), + + Stat::make(__('panel-admin::location.stats.states'), sprintf('%d/%d', $stats->statesReached, $stats->statesTotal)) + ->description(__('panel-admin::location.stats.states_hint')) + ->descriptionIcon(Heroicon::OutlinedGlobeAmericas) + ->color(Color::Blue), + ]; + } +} diff --git a/app-modules/panel-admin/src/Marketing/Widgets/TopStatesWidget.php b/app-modules/panel-admin/src/Marketing/Widgets/TopStatesWidget.php new file mode 100644 index 000000000..3be40a523 --- /dev/null +++ b/app-modules/panel-admin/src/Marketing/Widgets/TopStatesWidget.php @@ -0,0 +1,31 @@ + + */ + protected function getViewData(): array + { + $members = new MembersByState()->get(); + + return [ + 'top' => $members->top, + 'total' => $members->total, + 'statesReached' => $members->statesReached, + ]; + } +} diff --git a/app-modules/panel-admin/src/Moderation/Livewire/ModerationQueue.php b/app-modules/panel-admin/src/Moderation/Livewire/ModerationQueue.php index f44359813..16080f915 100644 --- a/app-modules/panel-admin/src/Moderation/Livewire/ModerationQueue.php +++ b/app-modules/panel-admin/src/Moderation/Livewire/ModerationQueue.php @@ -145,7 +145,6 @@ public function handleAction(): Action 'duration' => $data['duration'], 'reason' => $data['reason'], 'automated' => false, - 'tenant_id' => $case->tenant_id, ]); if ($case->author) { diff --git a/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource/Pages/ViewModerationCase.php b/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource/Pages/ViewModerationCase.php index 53521687b..39633eef8 100644 --- a/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource/Pages/ViewModerationCase.php +++ b/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource/Pages/ViewModerationCase.php @@ -110,7 +110,6 @@ protected function getHeaderActions(): array 'duration' => $data['duration'], 'reason' => $data['reason'], 'automated' => false, - 'tenant_id' => $case->tenant_id, ]); if ($case->author) { diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index 73ae45dfb..6df71329b 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -7,6 +7,7 @@ use Filament\Navigation\NavigationBuilder; use Filament\Navigation\NavigationItem; use Filament\Panel; +use He4rt\PanelAdmin\Discord\DiscordCluster; use He4rt\PanelAdmin\Filament\Resources\Events\EventResource; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; use He4rt\PanelAdmin\Github\GithubCluster; @@ -32,7 +33,13 @@ public function register(): void } $panel - ->pages([ModerationCluster::class, MarketingCluster::class, TwitchCluster::class, GithubCluster::class]) + ->pages([ + ModerationCluster::class, + MarketingCluster::class, + TwitchCluster::class, + GithubCluster::class, + DiscordCluster::class, + ]) ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, @@ -58,9 +65,17 @@ public function register(): void in: __DIR__.'/Twitch/Pages', for: 'He4rt\\PanelAdmin\\Twitch\\Pages', ) + ->discoverPages( + in: __DIR__.'/Discord/Pages', + for: 'He4rt\\PanelAdmin\\Discord\\Pages', + ) ->discoverResources( in: __DIR__.'/Github/Resources', for: 'He4rt\\PanelAdmin\\Github\\Resources', + ) + ->discoverResources( + in: __DIR__.'/Discord/Resources', + for: 'He4rt\\PanelAdmin\\Discord\\Resources', ); }); } @@ -77,7 +92,6 @@ public function boot(): void private function buildNavigation(NavigationBuilder $builder): NavigationBuilder { - request(); $requestPath = str(request()->path()); // Livewire update requests go to /livewire/update, so the @@ -98,6 +112,7 @@ private function buildNavigation(NavigationBuilder $builder): NavigationBuilder $requestPath->contains('mod/') => $this->moderationNavigation($builder), $requestPath->contains('marketing/') => $this->marketingNavigation($builder), $requestPath->contains('twitch/') => $this->twitchNavigation($builder), + $requestPath->contains('discord/') => $this->discordNavigation($builder), default => $this->defaultNavigation($builder), }; @@ -113,6 +128,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...GithubCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), ...EventResource::getNavigationItems(), + ...DiscordCluster::getNavigationItems(), ]); } @@ -127,6 +143,17 @@ private function moderationNavigation(NavigationBuilder $builder): NavigationBui ])->groups(resolve(ModerationCluster::class)->getCachedSubNavigation()); } + private function discordNavigation(NavigationBuilder $builder): NavigationBuilder + { + return $builder->items([ + NavigationItem::make(__('panel-admin::marketing.navigation.back_to_admin')) + ->sort(0) + ->icon('heroicon-o-arrow-left') + ->url(Dashboard::getUrl()), + + ])->groups(resolve(DiscordCluster::class)->getCachedSubNavigation()); + } + private function marketingNavigation(NavigationBuilder $builder): NavigationBuilder { return $builder->items([ diff --git a/app-modules/panel-admin/src/Tenant/EditTenantProfilePage.php b/app-modules/panel-admin/src/Tenant/EditTenantProfilePage.php deleted file mode 100644 index 497fecebc..000000000 --- a/app-modules/panel-admin/src/Tenant/EditTenantProfilePage.php +++ /dev/null @@ -1,48 +0,0 @@ -components([ - Section::make('General') - ->schema([ - Grid::make(2)->schema([ - TextInput::make('name') - ->required() - ->maxLength(255), - TextInput::make('slug') - ->required() - ->maxLength(255) - ->unique(ignoreRecord: true), - ]), - TextInput::make('domain') - ->maxLength(255), - Toggle::make('active') - ->default(state: true), - ]), - Section::make('Connections') - ->schema([ - Livewire::make('connection-hub'), - ]), - ]); - } -} diff --git a/app-modules/panel-admin/src/Twitch/Actions/ConnectTwitchAction.php b/app-modules/panel-admin/src/Twitch/Actions/ConnectTwitchAction.php new file mode 100644 index 000000000..25f39e104 --- /dev/null +++ b/app-modules/panel-admin/src/Twitch/Actions/ConnectTwitchAction.php @@ -0,0 +1,64 @@ +resolveConnectedIdentity(); + $isConnected = $connectedIdentity instanceof ExternalIdentity; + + $this + ->label($isConnected + ? __('panel-admin::twitch.connect.reconnect', ['login' => $this->resolveLogin($connectedIdentity)]) + : __('panel-admin::twitch.connect.connect')) + ->icon($isConnected ? 'heroicon-o-check-badge' : 'heroicon-o-link') + ->color($isConnected ? 'gray' : 'primary') + ->url(route('oauth.redirect', ['panel' => 'admin', 'provider' => 'twitch'])); + } + + public static function getDefaultName(): ?string + { + return 'connect-twitch'; + } + + private function resolveConnectedIdentity(): ?ExternalIdentity + { + $user = filament()->auth()->user(); + + if (!$user instanceof User) { + return null; + } + + $identity = $user->providers() + ->where('provider', IdentityProvider::Twitch) + ->whereNotNull('connected_at') + ->whereNull('disconnected_at') + ->first(); + + return $identity instanceof ExternalIdentity ? $identity : null; + } + + private function resolveLogin(ExternalIdentity $identity): string + { + $metadata = $identity->metadata ?? []; + + /** @var string $login */ + $login = $metadata['login'] + ?? $metadata['username'] + ?? $identity->external_account_id + ?? 'twitch'; + + return $login; + } +} diff --git a/app-modules/panel-admin/src/Twitch/Actions/RegisterSubscriptionsAction.php b/app-modules/panel-admin/src/Twitch/Actions/RegisterSubscriptionsAction.php index da7908cf8..472c014b3 100644 --- a/app-modules/panel-admin/src/Twitch/Actions/RegisterSubscriptionsAction.php +++ b/app-modules/panel-admin/src/Twitch/Actions/RegisterSubscriptionsAction.php @@ -9,7 +9,7 @@ use Filament\Support\Enums\Width; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\Identity\User\Models\User; use He4rt\IntegrationTwitch\Actions\RegisterTwitchSubscriptionsAction; use He4rt\IntegrationTwitch\Enums\TwitchEventSubType; use He4rt\IntegrationTwitch\Models\TwitchSubscription; @@ -28,48 +28,43 @@ protected function setUp(): void ->modalHeading(__('panel-admin::twitch.subscriptions.actions.register')) ->modalSubmitActionLabel(__('panel-admin::twitch.subscriptions.actions.register_confirm_button')) ->modalWidth(Width::ThreeExtraLarge) - ->modalContent(function (): View { - /** @var Tenant|null $tenant */ - $tenant = filament()->getTenant(); - - return view('panel-admin::twitch.register-subscriptions-modal', [ - 'broadcaster' => $this->resolveBroadcaster($tenant), - 'callbackUrl' => $this->resolveCallbackUrl($tenant), - 'groups' => $this->buildEventTypeGroups($tenant), - 'secret' => $this->maskSecret(), - ]); - }) - ->action(static function (): void { - /** @var Tenant|null $tenant */ - $tenant = filament()->getTenant(); - - if (!$tenant instanceof Tenant) { - return; - } - - $action = resolve(RegisterTwitchSubscriptionsAction::class); - $result = $action($tenant); - - if ($result['errors']['broadcaster'] ?? null) { + ->modalContent(fn (): View => view('panel-admin::twitch.register-subscriptions-modal', [ + 'broadcaster' => $this->resolveBroadcaster(), + 'callbackUrl' => $this->resolveCallbackUrl(), + 'groups' => $this->buildEventTypeGroups(), + 'secret' => $this->maskSecret(), + ])) + ->action(function (): void { + $broadcaster = $this->resolveBroadcaster(); + + if ($broadcaster === null) { Notification::make() ->danger() ->title(__('panel-admin::twitch.subscriptions.actions.register_failed')) - ->body($result['errors']['broadcaster']) + ->body(__('panel-admin::twitch.subscriptions.actions.no_broadcaster')) ->send(); return; } - Notification::make() - ->success() - ->title(__('panel-admin::twitch.subscriptions.actions.registered')) + $result = resolve(RegisterTwitchSubscriptionsAction::class)($broadcaster['id']); + + $hasFailures = $result['failed'] > 0; + + $notification = Notification::make() + ->title(__($hasFailures + ? 'panel-admin::twitch.subscriptions.actions.register_partial' + : 'panel-admin::twitch.subscriptions.actions.registered')) ->body(sprintf( '%d created, %d skipped, %d failed.', $result['created'], $result['skipped'], $result['failed'], - )) - ->send(); + )); + + $hasFailures ? $notification->warning() : $notification->success(); + + $notification->send(); }); } @@ -79,22 +74,27 @@ public static function getDefaultName(): ?string } /** + * The broadcaster is the Twitch account connected to the authenticated + * admin — no config/env fallback. The admin connects it via the + * "Connect Twitch" action on this page. + * * @return array{id: string, login: string}|null */ - private function resolveBroadcaster(?Tenant $tenant): ?array + private function resolveBroadcaster(): ?array { - if (!$tenant instanceof Tenant) { + $user = filament()->auth()->user(); + + if (!$user instanceof User) { return null; } - $identity = ExternalIdentity::query() - ->where('tenant_id', $tenant->getKey()) + $identity = $user->providers() ->where('provider', IdentityProvider::Twitch) ->whereNotNull('connected_at') ->whereNull('disconnected_at') ->first(); - if (!$identity) { + if (!$identity instanceof ExternalIdentity) { return null; } @@ -104,16 +104,28 @@ private function resolveBroadcaster(?Tenant $tenant): ?array ]; } - private function resolveCallbackUrl(?Tenant $tenant): string + private function resolveCallbackUrl(): string { - $slug = $tenant instanceof Tenant ? $tenant->slug : 'default'; + // Cast instead of config()->string(): the key may be present-but-null when + // TWITCH_EVENTSUB_CALLBACK is unset, and config()->string() throws on null. + $configured = (string) config('services.twitch.eventsub_callback', ''); + + if ($configured !== '') { + return $configured; + } - return mb_rtrim(config('app.url'), '/').'/api/webhooks/twitch/eventsub/'.$slug; + return mb_rtrim(config()->string('app.url'), '/').'/api/webhooks/twitch/eventsub'; } private function maskSecret(): string { - $secret = config()->string('services.twitch.eventsub_secret'); + // Read defensively: TWITCH_EVENTSUB_SECRET has no config default (it must + // fail loud on the security paths), so the display must tolerate it being unset. + $secret = config('services.twitch.eventsub_secret'); + + if (!is_string($secret) || $secret === '') { + return '—'; + } return mb_substr($secret, 0, 4).str_repeat('*', max(mb_strlen($secret) - 4, 0)); } @@ -121,17 +133,12 @@ private function maskSecret(): string /** * @return array}> */ - private function buildEventTypeGroups(?Tenant $tenant): array + private function buildEventTypeGroups(): array { - $existingTypes = []; - - if ($tenant instanceof Tenant) { - $existingTypes = TwitchSubscription::query() - ->where('tenant_id', $tenant->getKey()) - ->where('status', 'enabled') - ->pluck('type') - ->all(); - } + $existingTypes = TwitchSubscription::query() + ->where('status', 'enabled') + ->pluck('type') + ->all(); $groups = [ 'stream' => ['label' => 'Stream', 'types' => []], diff --git a/app-modules/panel-admin/src/Twitch/Resources/TwitchSubscriptionResource/Pages/ListTwitchSubscriptions.php b/app-modules/panel-admin/src/Twitch/Resources/TwitchSubscriptionResource/Pages/ListTwitchSubscriptions.php index ba0eb3d8f..1438af817 100644 --- a/app-modules/panel-admin/src/Twitch/Resources/TwitchSubscriptionResource/Pages/ListTwitchSubscriptions.php +++ b/app-modules/panel-admin/src/Twitch/Resources/TwitchSubscriptionResource/Pages/ListTwitchSubscriptions.php @@ -10,6 +10,7 @@ use He4rt\IntegrationTwitch\Models\TwitchSubscription; use He4rt\IntegrationTwitch\Transport\Requests\EventSub\ListSubscriptions; use He4rt\IntegrationTwitch\Transport\TwitchHelixConnector; +use He4rt\PanelAdmin\Twitch\Actions\ConnectTwitchAction; use He4rt\PanelAdmin\Twitch\Actions\RegisterSubscriptionsAction; use He4rt\PanelAdmin\Twitch\Resources\TwitchSubscriptionResource; use Saloon\Exceptions\Request\RequestException; @@ -21,6 +22,7 @@ class ListTwitchSubscriptions extends ListRecords protected function getHeaderActions(): array { return [ + ConnectTwitchAction::make(), RegisterSubscriptionsAction::make(), Action::make('sync') ->label(__('panel-admin::twitch.subscriptions.actions.sync')) @@ -40,7 +42,6 @@ private function syncSubscriptions(): void /** @var array> $remoteSubscriptions */ $remoteSubscriptions = $response->json('data', []); - $tenantId = filament()->getTenant()?->getKey(); $syncedIds = []; foreach ($remoteSubscriptions as $sub) { @@ -57,9 +58,6 @@ private function syncSubscriptions(): void 'callback_url' => $sub['transport']['callback'] ?? null, 'cost' => $sub['cost'] ?? 0, 'version' => $sub['version'] ?? '1', - 'tenant_id' => $tenantId ?? TwitchSubscription::query() - ->where('subscription_id', $sub['id']) - ->value('tenant_id') ?? 0, ] ); @@ -67,7 +65,6 @@ private function syncSubscriptions(): void } TwitchSubscription::query() - ->where('tenant_id', $tenantId) ->whereNotIn('subscription_id', $syncedIds) ->delete(); diff --git a/app-modules/panel-admin/src/Twitch/Widgets/EventsByTypeChartWidget.php b/app-modules/panel-admin/src/Twitch/Widgets/EventsByTypeChartWidget.php index b1c18cf62..d0b761a09 100644 --- a/app-modules/panel-admin/src/Twitch/Widgets/EventsByTypeChartWidget.php +++ b/app-modules/panel-admin/src/Twitch/Widgets/EventsByTypeChartWidget.php @@ -25,10 +25,7 @@ protected function getType(): string protected function getData(): array { - $tenantId = filament()->getTenant()?->getKey(); - $counts = TwitchEventLog::query() - ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->selectRaw('event_type, COUNT(*) as total') ->groupBy('event_type') ->orderByDesc('total') diff --git a/app-modules/panel-admin/src/Twitch/Widgets/EventsPerDayChartWidget.php b/app-modules/panel-admin/src/Twitch/Widgets/EventsPerDayChartWidget.php index fb30b3481..a0bdbef31 100644 --- a/app-modules/panel-admin/src/Twitch/Widgets/EventsPerDayChartWidget.php +++ b/app-modules/panel-admin/src/Twitch/Widgets/EventsPerDayChartWidget.php @@ -27,10 +27,7 @@ protected function getData(): array { $start = now()->subDays(29)->startOfDay(); - $tenantId = filament()->getTenant()?->getKey(); - $events = TwitchEventLog::query() - ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->where('created_at', '>=', $start) ->selectRaw('DATE(created_at) as date, COUNT(*) as total') ->groupBy('date') diff --git a/app-modules/panel-admin/src/Twitch/Widgets/TwitchStatsWidget.php b/app-modules/panel-admin/src/Twitch/Widgets/TwitchStatsWidget.php index e7ac2ca8d..cc0a0402a 100644 --- a/app-modules/panel-admin/src/Twitch/Widgets/TwitchStatsWidget.php +++ b/app-modules/panel-admin/src/Twitch/Widgets/TwitchStatsWidget.php @@ -23,28 +23,18 @@ class TwitchStatsWidget extends StatsOverviewWidget */ protected function getStats(): array { - $tenantId = filament()->getTenant()?->getKey(); + $totalEvents = TwitchEventLog::query()->count(); - $totalEvents = TwitchEventLog::query() - ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) + $eventsToday = TwitchEventLog::query()->where('created_at', '>=', today()) ->count(); - $eventsToday = TwitchEventLog::query() - ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) - ->where('created_at', '>=', today()) + $activeSubs = TwitchSubscription::query()->where('status', TwitchSubscriptionStatus::Enabled) ->count(); - $activeSubs = TwitchSubscription::query() - ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) - ->where('status', TwitchSubscriptionStatus::Enabled) - ->count(); - - $errorSubs = TwitchSubscription::query() - ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) - ->whereNotIn('status', [ - TwitchSubscriptionStatus::Enabled->value, - TwitchSubscriptionStatus::VerificationPending->value, - ]) + $errorSubs = TwitchSubscription::query()->whereNotIn('status', [ + TwitchSubscriptionStatus::Enabled->value, + TwitchSubscriptionStatus::VerificationPending->value, + ]) ->count(); return [ diff --git a/app-modules/panel-admin/tests/Feature/AdminPanelAccessTest.php b/app-modules/panel-admin/tests/Feature/AdminPanelAccessTest.php index f61cdf54f..30ccf7e8d 100644 --- a/app-modules/panel-admin/tests/Feature/AdminPanelAccessTest.php +++ b/app-modules/panel-admin/tests/Feature/AdminPanelAccessTest.php @@ -3,14 +3,11 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; test('unauthenticated user is redirected to login', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $this - ->get('/admin/'.$tenant->slug) + ->get('/admin') ->assertRedirect('/admin/login'); }); @@ -21,17 +18,14 @@ }); test('authenticated admin can access admin panel', function (): void { - $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); $user = User::factory()->create(['username' => 'danielhe4rt']); - $tenant->members()->attach($user); - config(['he4rt.admins' => 'danielhe4rt']); $this ->actingAs($user) ->get('/admin') - ->assertRedirect(); + ->assertOk(); }); test('admin user can access panel via canAccessPanel', function (): void { diff --git a/app-modules/panel-admin/tests/Feature/ApplyTenantScopesTest.php b/app-modules/panel-admin/tests/Feature/ApplyTenantScopesTest.php deleted file mode 100644 index 21a16e334..000000000 --- a/app-modules/panel-admin/tests/Feature/ApplyTenantScopesTest.php +++ /dev/null @@ -1,71 +0,0 @@ -create(); - - Filament::shouldReceive('getTenant') - ->andReturn($tenant); - - config(['panel-admin.tenant_scoped_models' => [ - Character::class, - ]]); - - $middleware = new ApplyTenantScopes(); - - $middleware->handle( - Request::create('/admin'), - fn () => new Response(), - ); - - $query = Character::query()->toSql(); - - expect($query)->toContain('where'); -}); - -test('skips scope application when no tenant is set', function (): void { - Filament::shouldReceive('getTenant') - ->andReturn(null); - - config(['panel-admin.tenant_scoped_models' => [ - User::class, - ]]); - - $middleware = new ApplyTenantScopes(); - - $middleware->handle( - Request::create('/admin'), - fn () => new Response(), - ); - - $query = User::query()->toSql(); - - expect($query)->not->toContain('tenant_id'); -}); - -test('handles empty tenant_scoped_models config', function (): void { - $tenant = Tenant::factory()->create(); - - Filament::shouldReceive('getTenant') - ->andReturn($tenant); - - config(['panel-admin.tenant_scoped_models' => []]); - - $middleware = new ApplyTenantScopes(); - - $response = $middleware->handle( - Request::create('/admin'), - fn () => new Response('ok'), - ); - - expect($response->getContent())->toBe('ok'); -}); diff --git a/app-modules/panel-admin/tests/Feature/Discord/DiscordClusterSmokeTest.php b/app-modules/panel-admin/tests/Feature/Discord/DiscordClusterSmokeTest.php new file mode 100644 index 000000000..bb42ef818 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Discord/DiscordClusterSmokeTest.php @@ -0,0 +1,211 @@ +create(['username' => 'danielhe4rt']); + + config(['he4rt.admins' => 'danielhe4rt']); + + $this->actingAs($user); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('lista as guilds', function (): void { + $guild = DiscordGuild::factory()->create(); + + livewire(ListDiscordGuilds::class) + ->loadTable() + ->assertCanSeeTableRecords([$guild]); +}); + +test('exibe a view da guild', function (): void { + $guild = DiscordGuild::factory()->create(); + + livewire(ViewDiscordGuild::class, ['record' => $guild->id]) + ->assertOk(); +}); + +test('lista canais agrupados sem categorias', function (): void { + $guild = DiscordGuild::factory()->create(); + + $category = DiscordChannel::factory() + ->category() + ->recycle($guild) + ->create(); + + $channels = DiscordChannel::factory() + ->count(2) + ->recycle($guild) + ->create(['parent_id' => $category->id]); + + livewire(ListDiscordChannels::class) + ->loadTable() + ->assertCanSeeTableRecords($channels) + ->assertCanNotSeeTableRecords([$category]); +}); + +test('pagina canais agrupados com mais de uma página', function (): void { + // Regressão: com PaginationMode::Cursor (padrão global do painel), renderizar + // o link da próxima página de uma tabela agrupada por relacionamento estoura + // em array_flip() — só reproduz com mais registros que o page size (10). + $guild = DiscordGuild::factory()->create(); + + $categories = DiscordChannel::factory() + ->count(2) + ->category() + ->recycle($guild) + ->create(); + + foreach ($categories as $category) { + DiscordChannel::factory() + ->count(8) + ->recycle($guild) + ->create(['parent_id' => $category->id]); + } + + livewire(ListDiscordChannels::class) + ->loadTable() + ->assertOk(); +}); + +test('exibe a view do canal', function (): void { + $guild = DiscordGuild::factory()->create(); + $channel = DiscordChannel::factory()->recycle($guild)->create(); + + livewire(ViewDiscordChannel::class, ['record' => $channel->id]) + ->assertOk(); +}); + +test('lista membros ativos por padrão', function (): void { + $guild = DiscordGuild::factory()->create(); + + $active = DiscordMember::factory()->recycle($guild)->create(); + $left = DiscordMember::factory()->recycle($guild)->left()->create(); + + livewire(ListDiscordMembers::class) + ->loadTable() + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$left]) + ->filterTable('left_at', true) + ->assertCanSeeTableRecords([$left]) + ->assertCanNotSeeTableRecords([$active]); +}); + +test('exibe a view do membro', function (): void { + $guild = DiscordGuild::factory()->create(); + $member = DiscordMember::factory()->recycle($guild)->create(); + + livewire(ViewDiscordMember::class, ['record' => $member->id]) + ->assertOk(); +}); + +test('lista roles ordenadas por position desc', function (): void { + $guild = DiscordGuild::factory()->create(); + + $lower = DiscordRole::factory()->recycle($guild)->create(['position' => 1]); + $higher = DiscordRole::factory()->recycle($guild)->create(['position' => 10]); + + livewire(ListDiscordRoles::class) + ->loadTable() + ->assertCanSeeTableRecords([$higher, $lower], inOrder: true); +}); + +test('exibe a view do role', function (): void { + $guild = DiscordGuild::factory()->create(); + $role = DiscordRole::factory()->recycle($guild)->create(['color' => 0]); + + livewire(ViewDiscordRole::class, ['record' => $role->id]) + ->assertOk(); +}); + +test('lista event logs', function (): void { + $logs = DiscordEventLog::factory()->count(2)->create(); + + livewire(ListDiscordEventLogs::class) + ->loadTable() + ->assertCanSeeTableRecords($logs); +}); + +test('filtra event logs por tipo', function (): void { + $banEvent = DiscordEventLog::factory()->create(['event_type' => 'GUILD_BAN_ADD']); + $messageEvent = DiscordEventLog::factory()->create(['event_type' => 'MESSAGE_CREATE']); + + livewire(ListDiscordEventLogs::class) + ->loadTable() + ->filterTable('event_type', ['GUILD_BAN_ADD']) + ->assertCanSeeTableRecords([$banEvent]) + ->assertCanNotSeeTableRecords([$messageEvent]); +}); + +test('exibe a view do event log com payload', function (): void { + $log = DiscordEventLog::factory()->create(); + + livewire(ViewDiscordEventLog::class, ['record' => $log->id]) + ->assertOk(); +}); + +test('dashboard renderiza', function (): void { + livewire(DiscordDashboard::class) + ->assertOk(); + + livewire(DiscordStatsWidget::class) + ->assertOk(); + + livewire(EventsPerDayChartWidget::class) + ->assertOk(); + + livewire(MemberGrowthChartWidget::class) + ->assertOk(); +}); + +test('relation managers da guild renderizam', function (): void { + $guild = DiscordGuild::factory()->create(); + + DiscordChannel::factory()->recycle($guild)->create(); + DiscordRole::factory()->recycle($guild)->create(); + DiscordMember::factory()->recycle($guild)->create(); + + livewire(ChannelsRelationManager::class, [ + 'ownerRecord' => $guild, + 'pageClass' => ViewDiscordGuild::class, + ])->assertOk(); + + livewire(RolesRelationManager::class, [ + 'ownerRecord' => $guild, + 'pageClass' => ViewDiscordGuild::class, + ])->assertOk(); + + livewire(MembersRelationManager::class, [ + 'ownerRecord' => $guild, + 'pageClass' => ViewDiscordGuild::class, + ])->assertOk(); +}); diff --git a/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php index 184d091e2..b65b42340 100644 --- a/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php @@ -4,7 +4,6 @@ use Filament\Actions\Testing\TestAction; use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\IntegrationGithub\Backfill\Jobs\BackfillGithubRepository; use He4rt\IntegrationGithub\Models\GithubRepository; @@ -16,23 +15,15 @@ beforeEach(function (): void { $user = User::factory()->create(['username' => 'danielhe4rt']); - $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $tenant->members()->attach($user); config(['he4rt.admins' => 'danielhe4rt']); $this->actingAs($user); Filament::setCurrentPanel(Filament::getPanel('admin')); - Filament::setTenant($tenant); - // Boota o painel para registrar o global scope + o observer de tenancy - // (em testes Livewire o painel não passa pelo middleware que faz isso no HTTP). - Filament::getPanel('admin')->boot(); - - $this->tenant = $tenant; }); -test('admin cria um repositório associando ao tenant atual', function (): void { +test('admin cria um repositório', function (): void { livewire(CreateGithubRepository::class) ->fillForm(['full_name' => 'he4rt/heartdevs.com', 'enabled' => true]) ->call('create') @@ -40,7 +31,7 @@ $repo = GithubRepository::query()->where('full_name', 'he4rt/heartdevs.com')->sole(); - expect($repo->tenant_id)->toBe($this->tenant->id); + expect($repo)->not->toBeNull(); }); test('rejeita full_name sem owner/repo', function (): void { @@ -50,7 +41,7 @@ ->assertHasFormErrors(['full_name']); }); -test('rejeita full_name duplicado no mesmo tenant', function (): void { +test('rejeita full_name duplicado', function (): void { GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); livewire(CreateGithubRepository::class) @@ -59,18 +50,12 @@ ->assertHasFormErrors(['full_name']); }); -test('lista apenas os repositórios do tenant atual', function (): void { +test('lista os repositórios cadastrados', function (): void { $mine = GithubRepository::factory()->create(['full_name' => 'he4rt/mine']); - $other = Tenant::factory()->create(['slug' => 'outra']); - Filament::setTenant($other); - $theirs = GithubRepository::factory()->create(['full_name' => 'he4rt/theirs']); - Filament::setTenant($this->tenant); - livewire(ListGithubRepositories::class) ->loadTable() - ->assertCanSeeTableRecords([$mine]) - ->assertCanNotSeeTableRecords([$theirs]); + ->assertCanSeeTableRecords([$mine]); }); test('backfill pelo painel enfileira o job em segundo plano', function (): void { diff --git a/app-modules/panel-admin/tests/Feature/Marketing/CommunityActivityStatsTest.php b/app-modules/panel-admin/tests/Feature/Marketing/CommunityActivityStatsTest.php new file mode 100644 index 000000000..cde45bf14 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Marketing/CommunityActivityStatsTest.php @@ -0,0 +1,62 @@ +tz = config('app.display_timezone'); + Date::setTestNow(Date::create(2_026, 6, 15, 12, 0, 0, $this->tz)); + + Http::fake([ + 'world.bmbc.cloud/api/countries*' => Http::response([ + 'data' => [['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil']], + ]), + 'world.bmbc.cloud/api/states*' => Http::response([ + 'data' => [['id' => 1, 'name' => 'São Paulo']], + ]), + ]); +}); + +afterEach(function (): void { + Date::setTestNow(); +}); + +test('it counts distinct web and discord actives within the current window', function (): void { + $tz = $this->tz; + + $userA = User::factory()->create(); + $userB = User::factory()->create(); + $userC = User::factory()->create(); + + // Web: userA (twice) and userB post inside the 30-day window; userC only outside. + Timeline::factory()->create(['user_id' => $userA->id, 'created_at' => Date::create(2_026, 6, 10, 12, 0, 0, $tz)]); + Timeline::factory()->create(['user_id' => $userA->id, 'created_at' => Date::create(2_026, 6, 12, 12, 0, 0, $tz)]); + Timeline::factory()->create(['user_id' => $userB->id, 'created_at' => Date::create(2_026, 6, 1, 12, 0, 0, $tz)]); + Timeline::factory()->create(['user_id' => $userC->id, 'created_at' => Date::create(2_026, 1, 1, 12, 0, 0, $tz)]); + + // Discord: one distinct identity with a message inside the window. + $identity = ExternalIdentity::factory()->create(); + Message::factory()->create(['external_identity_id' => $identity->id, 'sent_at' => Date::create(2_026, 6, 11, 12, 0, 0, $tz)]); + + // Location coverage: one located member. + Address::factory()->forUser($userA)->create(['state' => 'São Paulo']); + + $stats = new CommunityActivityStats(30)->get(); + + expect($stats->webActive)->toBe(2) + ->and($stats->discordActive)->toBe(1) + ->and($stats->locatedMembers)->toBe(1) + ->and($stats->statesReached)->toBe(1) + ->and($stats->statesTotal)->toBe(1); +}); diff --git a/app-modules/panel-admin/tests/Feature/Marketing/LocationDashboardTest.php b/app-modules/panel-admin/tests/Feature/Marketing/LocationDashboardTest.php new file mode 100644 index 000000000..ee8c03bc0 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Marketing/LocationDashboardTest.php @@ -0,0 +1,35 @@ + Http::response([ + 'data' => [['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil']], + ]), + 'world.bmbc.cloud/api/states*' => Http::response([ + 'data' => [['id' => 1, 'name' => 'São Paulo']], + ]), + ]); + + $admin = User::factory()->create(); + + $this->actingAs($admin); + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('the location dashboard renders for an admin', function (): void { + livewire(LocationDashboard::class) + ->assertSuccessful() + ->assertSee(__('panel-admin::location.map.heading')); +}); diff --git a/app-modules/panel-admin/tests/Feature/Marketing/MeetingShowcasePageTest.php b/app-modules/panel-admin/tests/Feature/Marketing/MeetingShowcasePageTest.php new file mode 100644 index 000000000..3301a131d --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Marketing/MeetingShowcasePageTest.php @@ -0,0 +1,186 @@ + 'America/Sao_Paulo']); + + $admin = User::factory()->create(); + + $this->actingAs($admin); + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('it loads Discord participant data from every supported metadata shape', function (): void { + $metadataByAccount = [ + '111' => [ + 'username' => 'root-user', + 'global_name' => 'Root User', + 'avatar' => 'https://cdn.example.com/root.png', + ], + '222' => [ + 'user' => [ + 'username' => 'profile-user', + 'global_name' => 'Profile User', + 'avatar' => 'profile-avatar', + ], + ], + '333' => [ + 'author' => [ + 'username' => 'message-user', + 'global_name' => 'Message User', + 'avatar' => 'message-avatar', + ], + ], + ]; + + foreach ($metadataByAccount as $accountId => $metadata) { + $identity = ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'external_account_id' => $accountId, + 'metadata' => $metadata, + ]); + + Message::factory()->create([ + 'external_identity_id' => $identity->id, + 'channel_id' => 'meeting-channel', + 'sent_at' => Date::parse('2026-08-03 22:30:00', 'America/Sao_Paulo')->utc(), + ]); + + if ((string) $accountId === '111') { + Message::factory()->create([ + 'external_identity_id' => $identity->id, + 'channel_id' => 'meeting-channel', + 'sent_at' => Date::parse('2026-08-03 22:31:00', 'America/Sao_Paulo')->utc(), + ]); + } + } + + $linkedUser = User::factory()->create([ + 'username' => 'linked-user', + 'name' => 'Linked User', + ]); + + $identityWithoutMetadata = ExternalIdentity::factory()->create([ + 'model_id' => $linkedUser->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '444', + 'metadata' => [], + ]); + + Message::factory()->create([ + 'external_identity_id' => $identityWithoutMetadata->id, + 'channel_id' => 'meeting-channel', + 'sent_at' => Date::parse('2026-08-03 22:45:00', 'America/Sao_Paulo')->utc(), + ]); + + $disconnectedIdentity = ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '555', + 'metadata' => [ + 'author' => [ + 'username' => 'disconnected-user', + 'global_name' => 'Disconnected User', + ], + ], + ]); + + Message::factory()->create([ + 'external_identity_id' => $disconnectedIdentity->id, + 'channel_id' => 'meeting-channel', + 'sent_at' => Date::parse('2026-08-03 23:00:00', 'America/Sao_Paulo')->utc(), + ]); + + $disconnectedIdentity->delete(); + + $component = livewire(MeetingShowcasePage::class) + ->set('channelId', 'meeting-channel') + ->set('startDate', '2026-08-03T22:00') + ->set('endDate', '2026-08-03T23:40') + ->call('loadParticipants') + ->assertSet('loaded', value: true); + + /** @var array $participants */ + $participants = $component->get('participants'); + $participantsByAccount = collect($participants)->keyBy('discord_id'); + + expect($participants)->toHaveCount(5) + ->and($participantsByAccount->get('111'))->toMatchArray([ + 'username' => 'root-user', + 'global_name' => 'Root User', + 'avatar_url' => 'https://cdn.example.com/root.png', + 'total_messages' => 2, + ]) + ->and($participantsByAccount->get('222'))->toMatchArray([ + 'username' => 'profile-user', + 'global_name' => 'Profile User', + 'avatar_url' => 'https://cdn.discordapp.com/avatars/222/profile-avatar.png?size=128', + ]) + ->and($participantsByAccount->get('333'))->toMatchArray([ + 'username' => 'message-user', + 'global_name' => 'Message User', + 'avatar_url' => 'https://cdn.discordapp.com/avatars/333/message-avatar.png?size=128', + ]) + ->and($participantsByAccount->get('444'))->toMatchArray([ + 'username' => 'linked-user', + 'global_name' => 'Linked User', + ]) + ->and($participantsByAccount->get('555'))->toMatchArray([ + 'username' => 'disconnected-user', + 'global_name' => 'Disconnected User', + ]); +}); + +test('it includes messages sent during the selected end minute', function (): void { + $identity = ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '444', + 'metadata' => ['username' => 'last-minute-user'], + ]); + + Message::factory()->create([ + 'external_identity_id' => $identity->id, + 'channel_id' => 'meeting-channel', + 'sent_at' => Date::parse('2026-08-03 23:40:59', 'America/Sao_Paulo')->utc(), + ]); + + livewire(MeetingShowcasePage::class) + ->set('channelId', 'meeting-channel') + ->set('startDate', '2026-08-03T22:00') + ->set('endDate', '2026-08-03T23:40') + ->call('loadParticipants') + ->assertSet('loaded', value: true) + ->assertSet('participants.0.username', 'last-minute-user'); +}); + +test('it excludes messages sent after the selected end minute', function (): void { + $identity = ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '445', + 'metadata' => ['username' => 'too-late-user'], + ]); + + Message::factory()->create([ + 'external_identity_id' => $identity->id, + 'channel_id' => 'meeting-channel', + 'sent_at' => Date::parse('2026-08-03 23:41:00', 'America/Sao_Paulo')->utc(), + ]); + + livewire(MeetingShowcasePage::class) + ->set('channelId', 'meeting-channel') + ->set('startDate', '2026-08-03T22:00') + ->set('endDate', '2026-08-03T23:40') + ->call('loadParticipants') + ->assertSet('loaded', value: true) + ->assertSet('participants', []); +}); diff --git a/app-modules/panel-admin/tests/Feature/Marketing/MembersByStateTest.php b/app-modules/panel-admin/tests/Feature/Marketing/MembersByStateTest.php new file mode 100644 index 000000000..1b6960610 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Marketing/MembersByStateTest.php @@ -0,0 +1,60 @@ + Http::response([ + 'data' => [ + ['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil'], + ], + ]), + 'world.bmbc.cloud/api/states*' => Http::response([ + 'data' => [ + ['id' => 1, 'name' => 'São Paulo'], + ['id' => 2, 'name' => 'Minas Gerais'], + ['id' => 3, 'name' => 'Bahia'], + ], + ]), + ]); +}); + +test('it aggregates members by state, normalizing accents and casing', function (): void { + $sp1 = User::factory()->create(); + $sp2 = User::factory()->create(); + $mg = User::factory()->create(); + + Address::factory()->forUser($sp1)->create(['state' => 'São Paulo']); + Address::factory()->forUser($sp2)->create(['state' => 'sao paulo']); // accent/case variation + Address::factory()->forUser($mg)->create(['state' => 'Minas Gerais']); + + $data = new MembersByState()->get(); + + expect($data->byName['sao paulo'])->toBe(2) + ->and($data->byName['minas gerais'])->toBe(1) + ->and($data->total)->toBe(3) + ->and($data->statesReached)->toBe(2) + ->and($data->statesTotal)->toBe(3) + ->and($data->top[0]->name)->toBe('São Paulo') + ->and($data->top[0]->members)->toBe(2) + ->and($data->top[0]->share)->toBe(66.7); +}); + +test('it ignores addresses whose state is not a recognized brazilian state', function (): void { + $user = User::factory()->create(); + + Address::factory()->forUser($user)->create(['state' => 'Nowhere']); + + $data = new MembersByState()->get(); + + expect($data->total)->toBe(0) + ->and($data->byName)->toBeEmpty(); +}); diff --git a/app-modules/panel-admin/tests/Feature/Marketing/PeriodStatsTest.php b/app-modules/panel-admin/tests/Feature/Marketing/PeriodStatsTest.php index a3d589264..f525b998d 100644 --- a/app-modules/panel-admin/tests/Feature/Marketing/PeriodStatsTest.php +++ b/app-modules/panel-admin/tests/Feature/Marketing/PeriodStatsTest.php @@ -5,7 +5,6 @@ use Carbon\CarbonInterface; use He4rt\Activity\Message\Models\Message; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\PanelAdmin\Marketing\Pages\Discord\Dashboard\Queries\PeriodStats; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -29,40 +28,38 @@ test('it buckets messages, distinct users and voice joins into the correct daily blocks', function (): void { $tz = $this->displayTimezone; - $tenant = Tenant::factory()->create(); - $identityA = ExternalIdentity::factory()->recycle($tenant)->create(); - $identityB = ExternalIdentity::factory()->recycle($tenant)->create(); + $identityA = ExternalIdentity::factory()->create(); + $identityB = ExternalIdentity::factory()->create(); $firstBlockNoon = Date::create(2_026, 6, 8, 12, 0, 0, $tz); // block 0 (oldest day) $lastBlockNoon = Date::create(2_026, 6, 14, 12, 0, 0, $tz); // block 6 (most recent full day) $todayNoon = Date::create(2_026, 6, 15, 12, 0, 0, $tz); // outside every block // Block 0: three messages from two distinct identities. - Message::factory()->count(2)->recycle($tenant)->create([ + Message::factory()->count(2)->create([ 'external_identity_id' => $identityA->id, 'sent_at' => $firstBlockNoon, ]); - Message::factory()->recycle($tenant)->create([ + Message::factory()->create([ 'external_identity_id' => $identityB->id, 'sent_at' => $firstBlockNoon, ]); // Block 6: two messages from a single identity. - Message::factory()->count(2)->recycle($tenant)->create([ + Message::factory()->count(2)->create([ 'external_identity_id' => $identityA->id, 'sent_at' => $lastBlockNoon, ]); // Today's message falls outside all seven blocks and must not be counted. - Message::factory()->recycle($tenant)->create([ + Message::factory()->create([ 'external_identity_id' => $identityA->id, 'sent_at' => $todayNoon, ]); - $insertVoice = static function (string $state, CarbonInterface $occurredAt) use ($identityA, $tenant): void { + $insertVoice = static function (string $state, CarbonInterface $occurredAt) use ($identityA): void { DB::table('voice_messages')->insert([ 'external_identity_id' => $identityA->id, - 'tenant_id' => $tenant->id, 'channel_name' => 'general', 'state' => $state, 'obtained_experience' => 0, diff --git a/app-modules/panel-admin/tests/Feature/Moderation/AppealQueueTest.php b/app-modules/panel-admin/tests/Feature/Moderation/AppealQueueTest.php index 4cf236f27..43fbe57cb 100644 --- a/app-modules/panel-admin/tests/Feature/Moderation/AppealQueueTest.php +++ b/app-modules/panel-admin/tests/Feature/Moderation/AppealQueueTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Appeals\ModerationAppeal; use He4rt\Moderation\Cases\Models\ModerationCase; @@ -14,15 +13,12 @@ beforeEach(function (): void { $this->user = User::factory()->create(['username' => 'danielhe4rt']); - $this->tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $this->tenant->members()->attach($this->user); config(['he4rt.admins' => 'danielhe4rt']); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('admin')); - Filament::setTenant($this->tenant); }); test('appeal queue renders with appeals ordered by sla_deadline', function (): void { diff --git a/app-modules/panel-admin/tests/Feature/Moderation/ModerationDashboardTest.php b/app-modules/panel-admin/tests/Feature/Moderation/ModerationDashboardTest.php index 6eddbac99..85414ce26 100644 --- a/app-modules/panel-admin/tests/Feature/Moderation/ModerationDashboardTest.php +++ b/app-modules/panel-admin/tests/Feature/Moderation/ModerationDashboardTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Appeals\ModerationAppeal; use He4rt\Moderation\Cases\Models\ModerationCase; @@ -15,15 +14,12 @@ beforeEach(function (): void { $user = User::factory()->create(['username' => 'danielhe4rt']); - $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $tenant->members()->attach($user); config(['he4rt.admins' => 'danielhe4rt']); $this->actingAs($user); Filament::setCurrentPanel(Filament::getPanel('admin')); - Filament::setTenant($tenant); }); test('dashboard page renders successfully', function (): void { @@ -51,11 +47,8 @@ }); test('stats show correct pending count', function (): void { - $tenant = Filament::getTenant(); - ModerationCase::factory()->count(3)->create([ 'status' => 'pending', - 'tenant_id' => $tenant->id, ]); livewire(ModerationDashboardLivewire::class) diff --git a/app-modules/panel-admin/tests/Feature/Moderation/ModerationQueueTest.php b/app-modules/panel-admin/tests/Feature/Moderation/ModerationQueueTest.php index df68e808a..7cdc6d319 100644 --- a/app-modules/panel-admin/tests/Feature/Moderation/ModerationQueueTest.php +++ b/app-modules/panel-admin/tests/Feature/Moderation/ModerationQueueTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Cases\Models\ModerationCase; use He4rt\PanelAdmin\Moderation\Livewire\ModerationQueue; @@ -12,25 +11,20 @@ beforeEach(function (): void { $this->user = User::factory()->create(['username' => 'danielhe4rt']); - $this->tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $this->tenant->members()->attach($this->user); config(['he4rt.admins' => 'danielhe4rt']); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('admin')); - Filament::setTenant($this->tenant); }); test('queue page renders with cases ordered by priority', function (): void { $lowPriority = ModerationCase::factory()->create([ 'priority' => 10, - 'tenant_id' => $this->tenant->id, ]); $highPriority = ModerationCase::factory()->create([ 'priority' => 90, - 'tenant_id' => $this->tenant->id, ]); $component = livewire(ModerationQueue::class); @@ -41,9 +35,7 @@ }); test('queue auto-selects first case on mount', function (): void { - $case = ModerationCase::factory()->create([ - 'tenant_id' => $this->tenant->id, - ]); + $case = ModerationCase::factory()->create(); $component = livewire(ModerationQueue::class); @@ -53,12 +45,10 @@ test('queue filters cases by status', function (): void { ModerationCase::factory()->create([ 'status' => 'pending', - 'tenant_id' => $this->tenant->id, ]); ModerationCase::factory()->create([ 'status' => 'resolved', 'resolved_at' => now(), - 'tenant_id' => $this->tenant->id, ]); $component = livewire(ModerationQueue::class) @@ -73,11 +63,9 @@ test('queue filters cases by platform', function (): void { ModerationCase::factory()->create([ 'source_platform' => 'discord', - 'tenant_id' => $this->tenant->id, ]); ModerationCase::factory()->create([ 'source_platform' => 'twitch', - 'tenant_id' => $this->tenant->id, ]); $component = livewire(ModerationQueue::class) @@ -88,8 +76,8 @@ }); test('selecting a case updates selectedCaseId', function (): void { - $case1 = ModerationCase::factory()->create(['tenant_id' => $this->tenant->id]); - $case2 = ModerationCase::factory()->create(['tenant_id' => $this->tenant->id]); + $case1 = ModerationCase::factory()->create(); + $case2 = ModerationCase::factory()->create(); livewire(ModerationQueue::class) ->call('selectCase', $case2->id) @@ -100,13 +88,11 @@ $pending = ModerationCase::factory()->create([ 'status' => 'pending', 'priority' => 80, - 'tenant_id' => $this->tenant->id, ]); ModerationCase::factory()->create([ 'status' => 'resolved', 'resolved_at' => now(), 'priority' => 90, - 'tenant_id' => $this->tenant->id, ]); $component = livewire(ModerationQueue::class); @@ -117,7 +103,6 @@ test('dismiss action updates case status', function (): void { $case = ModerationCase::factory()->create([ 'status' => 'pending', - 'tenant_id' => $this->tenant->id, ]); livewire(ModerationQueue::class) @@ -133,7 +118,6 @@ test('escalate action updates case status', function (): void { $case = ModerationCase::factory()->create([ 'status' => 'pending', - 'tenant_id' => $this->tenant->id, ]); livewire(ModerationQueue::class) @@ -148,7 +132,6 @@ test('empty state shows when no cases match filters', function (): void { ModerationCase::factory()->create([ 'source_platform' => 'discord', - 'tenant_id' => $this->tenant->id, ]); $component = livewire(ModerationQueue::class) diff --git a/app-modules/panel-admin/tests/Feature/Moderation/TestRuleActionTest.php b/app-modules/panel-admin/tests/Feature/Moderation/TestRuleActionTest.php index b9f481160..9ee56ea15 100644 --- a/app-modules/panel-admin/tests/Feature/Moderation/TestRuleActionTest.php +++ b/app-modules/panel-admin/tests/Feature/Moderation/TestRuleActionTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Moderation\Rules\ModerationRule; use He4rt\PanelAdmin\Moderation\Resources\ModerationRuleResource\Pages\EditModerationRule; @@ -12,15 +11,12 @@ beforeEach(function (): void { $user = User::factory()->create(['username' => 'danielhe4rt']); - $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); - $tenant->members()->attach($user); config(['he4rt.admins' => 'danielhe4rt']); $this->actingAs($user); Filament::setCurrentPanel(Filament::getPanel('admin')); - Filament::setTenant($tenant); }); test('test rule action shows match notification for keyword rule', function (): void { diff --git a/app-modules/panel-admin/tests/Feature/Twitch/ConnectTwitchActionTest.php b/app-modules/panel-admin/tests/Feature/Twitch/ConnectTwitchActionTest.php new file mode 100644 index 000000000..7d9039f92 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Twitch/ConnectTwitchActionTest.php @@ -0,0 +1,48 @@ +user = User::factory()->create(['username' => 'danielhe4rt']); + + config(['he4rt.admins' => 'danielhe4rt']); + + $this->actingAs($this->user); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('botão de conectar aponta para o fluxo OAuth da twitch no painel admin', function (): void { + $expectedUrl = route('oauth.redirect', ['panel' => 'admin', 'provider' => 'twitch']); + + livewire(ListTwitchSubscriptions::class) + ->assertActionExists('connect-twitch') + ->assertActionHasUrl('connect-twitch', $expectedUrl) + ->assertActionHasLabel('connect-twitch', __('panel-admin::twitch.connect.connect')); +}); + +test('quando já existe identidade twitch conectada, o botão mostra reconectar com o login', function (): void { + ExternalIdentity::factory()->create([ + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $this->user->id, + 'provider' => IdentityProvider::Twitch, + 'connected_at' => now(), + 'disconnected_at' => null, + 'metadata' => ['login' => 'he4rtdevs'], + ]); + + livewire(ListTwitchSubscriptions::class) + ->assertActionExists('connect-twitch') + ->assertActionHasLabel( + 'connect-twitch', + __('panel-admin::twitch.connect.reconnect', ['login' => 'he4rtdevs']), + ); +}); diff --git a/app-modules/panel-admin/tests/Feature/Twitch/RegisterSubscriptionsActionTest.php b/app-modules/panel-admin/tests/Feature/Twitch/RegisterSubscriptionsActionTest.php new file mode 100644 index 000000000..695d269ad --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Twitch/RegisterSubscriptionsActionTest.php @@ -0,0 +1,31 @@ +create(['username' => 'danielhe4rt']); + + config(['he4rt.admins' => 'danielhe4rt']); + + $this->actingAs($user); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('o modal de registrar subscriptions monta mesmo sem eventsub_callback configurado', function (): void { + // Reproduz o gatilho exato do crash: a chave presente-porém-nula quando + // TWITCH_EVENTSUB_CALLBACK está unset. O callback deve derivar de app.url + // em vez de estourar em config()->string() com valor nulo. + config()->set('services.twitch.eventsub_callback'); + + livewire(ListTwitchSubscriptions::class) + ->mountAction('register-subscriptions') + ->assertActionMounted('register-subscriptions') + ->assertHasNoErrors(); +}); diff --git a/app-modules/panel-app/lang/en/feed.php b/app-modules/panel-app/lang/en/feed.php new file mode 100644 index 000000000..7d06c29a7 --- /dev/null +++ b/app-modules/panel-app/lang/en/feed.php @@ -0,0 +1,13 @@ + 'No posts yet. Be the first to share something!', + + 'composer' => [ + 'placeholder' => "What's happening?", + 'add_images' => 'Add images', + 'post' => 'Post', + ], +]; diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index 1eaac516c..c8461e68e 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -71,10 +71,29 @@ 'add_social_link' => 'Add social link', 'add_work_experience' => 'Add experience', 'add_skill' => 'Add skill', + 'change_avatar' => 'Change photo', + 'change_cover' => 'Change cover', + 'save_avatar' => 'Save photo', + 'save_cover' => 'Save cover', ], 'notifications' => [ 'saved' => 'Profile saved successfully!', + 'avatar_updated' => 'Photo updated successfully!', + 'cover_updated' => 'Cover updated successfully!', 'no_profile' => 'Profile not found for this tenant.', ], + + 'page' => [ + 'subtitle' => 'Fill in the fields and watch your card being built in real time', + ], + + 'preview' => [ + 'available' => 'Available', + 'years_experience' => ':count year of exp.|:count years of exp.', + 'skills' => 'Skills', + 'experience' => 'Experience', + 'can_start' => 'Can start:', + 'footer' => 'This card appears in the member listing and on your public profile.', + ], ]; diff --git a/app-modules/panel-app/lang/pt_BR/feed.php b/app-modules/panel-app/lang/pt_BR/feed.php new file mode 100644 index 000000000..100409a1d --- /dev/null +++ b/app-modules/panel-app/lang/pt_BR/feed.php @@ -0,0 +1,13 @@ + 'Nenhum post ainda. Seja o primeiro a compartilhar algo!', + + 'composer' => [ + 'placeholder' => 'O que está acontecendo?', + 'add_images' => 'Adicionar imagens', + 'post' => 'Postar', + ], +]; diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index 990dacc86..57671c1e8 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -71,10 +71,29 @@ 'add_social_link' => 'Adicionar link social', 'add_work_experience' => 'Adicionar experiência', 'add_skill' => 'Adicionar skill', + 'change_avatar' => 'Alterar foto', + 'change_cover' => 'Alterar capa', + 'save_avatar' => 'Salvar foto', + 'save_cover' => 'Salvar capa', ], 'notifications' => [ 'saved' => 'Perfil salvo com sucesso!', + 'avatar_updated' => 'Foto atualizada com sucesso!', + 'cover_updated' => 'Capa atualizada com sucesso!', 'no_profile' => 'Perfil não encontrado para este tenant.', ], + + 'page' => [ + 'subtitle' => 'Preencha os campos e veja seu card sendo montado em tempo real', + ], + + 'preview' => [ + 'available' => 'Disponível', + 'years_experience' => ':count ano de exp.|:count anos de exp.', + 'skills' => 'Skills', + 'experience' => 'Experiência', + 'can_start' => 'Pode iniciar:', + 'footer' => 'Esse card aparece na listagem de membros e no seu perfil público.', + ], ]; diff --git a/app-modules/panel-app/resources/js/last-auth-provider.js b/app-modules/panel-app/resources/js/last-auth-provider.js new file mode 100644 index 000000000..8780852cc --- /dev/null +++ b/app-modules/panel-app/resources/js/last-auth-provider.js @@ -0,0 +1,18 @@ +(() => { + const url = new URL(window.location.href); + const provider = url.searchParams.get('oauth_provider'); + const supportedProviders = ['discord', 'github', 'twitch']; + + if (!supportedProviders.includes(provider)) { + return; + } + + try { + window.localStorage.setItem('lastAuthProvider', provider); + } catch (error) { + // Ignore browsers that block localStorage access. + } + + url.searchParams.delete('oauth_provider'); + window.history.replaceState({}, document.title, url); +})(); diff --git a/app-modules/panel-app/resources/views/auth/login.blade.php b/app-modules/panel-app/resources/views/auth/login.blade.php index 67b2fdb93..5ef1432a0 100644 --- a/app-modules/panel-app/resources/views/auth/login.blade.php +++ b/app-modules/panel-app/resources/views/auth/login.blade.php @@ -72,37 +72,71 @@ class="absolute right-0 bottom-0 left-0 h-px bg-gradient-to-r from-transparent v He4rt Developers
-

Entrar

+

Entrar

Acesse sua conta He4rt Developers

{{-- OAuth --}} -
- ou + ou
diff --git a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php index 66d6d2353..e4fe4c211 100644 --- a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php +++ b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php @@ -2,7 +2,8 @@ 'avatarPreviewUrl' => null, 'coverPreviewUrl' => null, 'initials' => '', - 'name' => '' + 'name' => '', + 'birthdateForm' => null, ])
@@ -14,29 +15,26 @@ class="group relative h-40 overflow-hidden rounded-t-xl sm:h-48" @mouseleave="hover = false" > @if ($coverPreviewUrl) -
+ @else
@endif - - - + @if ($coverPreviewUrl) @endif - -
- -
{{-- Bottom section: avatar + fields --}} @@ -82,10 +72,12 @@ class="flex h-full w-full items-center justify-center rounded-full border-4 bord @endif - - - + @if ($avatarPreviewUrl) @endif - -
- -
{{-- Nickname + Birthdate --}} -
+
- - + {{ $birthdateForm }}
diff --git a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php index 5386dac5f..f10c5700d 100644 --- a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php +++ b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php @@ -123,7 +123,7 @@ class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-w class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-500/10 dark:text-green-400" > - Disponível + {{ __('panel-app::profile.preview.available') }} @endif @@ -165,7 +165,7 @@ class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text- @endif @if ($yearsExperience) - {{ $yearsExperience }} {{ $yearsExperience == 1 ? 'ano' : 'anos' }} de exp. + {{ trans_choice('panel-app::profile.preview.years_experience', $yearsExperience, ['count' => $yearsExperience]) }} @endif @@ -180,7 +180,7 @@ class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text- @if ($skills->isNotEmpty())
- Skills + {{ __('panel-app::profile.preview.skills') }}
@foreach ($skills as $skill) @@ -203,7 +203,7 @@ class="inline-flex items-center gap-1 rounded-md bg-purple-50 px-2 py-0.5 text-x
Experiência{{ __('panel-app::profile.preview.experience') }} - {{ $platform->getLabel() }} {{ $link['handle'] }} - + + + {{ $platform->getLabel() }} + + + + @endif @endforeach
@@ -257,13 +266,13 @@ class="inline-block rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 {{-- Start availability --}} @if ($available && $startAvailability)

- Pode iniciar: {{ $startAvailability->getLabel() }} + {{ __('panel-app::profile.preview.can_start') }} {{ $startAvailability->getLabel() }}

@endif
{{-- Footer --}}
-

Esse card aparece na listagem de membros e no seu perfil público.

+

{{ __('panel-app::profile.preview.footer') }}

diff --git a/app-modules/panel-app/resources/views/components/timeline/engagement.blade.php b/app-modules/panel-app/resources/views/components/timeline/engagement.blade.php index 15b250b62..87c84c331 100644 --- a/app-modules/panel-app/resources/views/components/timeline/engagement.blade.php +++ b/app-modules/panel-app/resources/views/components/timeline/engagement.blade.php @@ -31,7 +31,7 @@ class="hover:text-primary-500 flex items-center gap-1.5 transition"
- @if ($isOwner) + @if ($isOwner && config('he4rt.features.timeline_pin')) @@ -35,7 +43,7 @@ class="hover:bg-primary-500/10 hover:text-primary-500 rounded-full p-1.5 text-gr wire:loading.class="opacity-50" class="bg-primary-600 hover:bg-primary-500 rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-50" > - Postar + {{ __('panel-app::feed.composer.post') }} diff --git a/app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php b/app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php index cea7457e1..087c1f51b 100644 --- a/app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php +++ b/app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php @@ -12,7 +12,7 @@ @empty
-

Nenhum post ainda. Seja o primeiro a compartilhar algo!

+

{{ __('panel-app::feed.empty') }}

@endforelse diff --git a/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php b/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php index eee4e5d3c..e351f0a8d 100644 --- a/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php +++ b/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php @@ -16,11 +16,20 @@ @foreach ($timeline->children->take(3) as $reply) @continue (!$reply->postable || !$reply->user)
-
- {{ str($reply->user->name)->substr(0, 2)->upper() }} -
+ @php($replyAvatarUrl = $reply->user->getFirstMediaUrl('avatar') ?: null) + @if ($replyAvatarUrl) + {{ $reply->user->name }} + @else +
+ {{ str($reply->user->name)->substr(0, 2)->upper() }} +
+ @endif
{{ $reply->user->name }} diff --git a/app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php b/app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php index 4bbdedf24..1618ada38 100644 --- a/app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php +++ b/app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php @@ -4,15 +4,23 @@ class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-wh >
- + @if ($this->avatarUrl) + + @else + + @endif
{{ $this->form }}
diff --git a/app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php b/app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php index 582d6f9e2..1e11d522a 100644 --- a/app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php +++ b/app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php @@ -12,11 +12,20 @@ class="px-3 py-2.5 text-xs font-semibold tracking-wide text-gray-500 uppercase s @continue (!$reply->postable)
-
- {{ str($reply->user->name)->substr(0, 2)->upper() }} -
+ @php($replyAvatarUrl = $reply->user->getFirstMediaUrl('avatar') ?: null) + @if ($replyAvatarUrl) + {{ $reply->user->name }} + @else +
+ {{ str($reply->user->name)->substr(0, 2)->upper() }} +
+ @endif
diff --git a/app-modules/panel-app/resources/views/pages/profile.blade.php b/app-modules/panel-app/resources/views/pages/profile.blade.php index 3ccd6abb3..e2403e56d 100644 --- a/app-modules/panel-app/resources/views/pages/profile.blade.php +++ b/app-modules/panel-app/resources/views/pages/profile.blade.php @@ -1,6 +1,6 @@
- Preencha os campos e veja seu card sendo montado em tempo real + {{ __('panel-app::profile.page.subtitle') }}
@@ -11,7 +11,8 @@ 'avatarPreviewUrl' => $this->avatarPreviewUrl, 'coverPreviewUrl' => $this->coverPreviewUrl, 'initials' => $this->initials, - 'name' => auth()->user()->name + 'name' => auth()->user()->name, + 'birthdateForm' => $this->birthdateForm, ]) {{ $this->form }} @@ -37,4 +38,14 @@
+ diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php index 1b71eb6f2..203fbf39e 100644 --- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php +++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php @@ -61,7 +61,6 @@ public function event(): Event return Event::query() ->with('enrollmentPolicy') ->where('id', $this->eventId) - ->where('tenant_id', filament()->getTenant()->getKey()) ->viewableByParticipant() ->firstOrFail(); } diff --git a/app-modules/panel-app/src/Livewire/Events/EventsList.php b/app-modules/panel-app/src/Livewire/Events/EventsList.php index 4d3ba3f05..946a75764 100644 --- a/app-modules/panel-app/src/Livewire/Events/EventsList.php +++ b/app-modules/panel-app/src/Livewire/Events/EventsList.php @@ -17,7 +17,6 @@ public function getEventsProperty(): Collection { return Event::query() ->with('enrollmentPolicy') - ->where('tenant_id', filament()->getTenant()->getKey()) ->active() ->orderBy('starts_at') ->get(); diff --git a/app-modules/panel-app/src/Livewire/Events/MyEventsList.php b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php index 45af6f442..333f72b33 100644 --- a/app-modules/panel-app/src/Livewire/Events/MyEventsList.php +++ b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php @@ -7,7 +7,6 @@ use He4rt\Events\Enrollment\Models\Enrollment; use He4rt\Events\Event\Models\Event; use He4rt\PanelApp\Pages\EventPage; -use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Livewire\Component; @@ -20,7 +19,6 @@ public function getEnrollmentsProperty(): Collection return Enrollment::query() ->with(['event']) ->where('user_id', auth()->id()) - ->whereHas('event', fn (Builder $query) => $query->where('tenant_id', filament()->getTenant()->getKey())) ->latest('enrolled_at') ->get(); } diff --git a/app-modules/panel-app/src/Livewire/Timeline/Composer.php b/app-modules/panel-app/src/Livewire/Timeline/Composer.php index 288f0fb28..818bf371c 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/Composer.php +++ b/app-modules/panel-app/src/Livewire/Timeline/Composer.php @@ -4,7 +4,6 @@ namespace He4rt\PanelApp\Livewire\Timeline; -use Filament\Facades\Filament; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\MarkdownEditor; use Filament\Schemas\Concerns\InteractsWithSchemas; @@ -14,6 +13,7 @@ use He4rt\Activity\Timeline\DTOs\CreatePostDTO; use He4rt\Identity\User\Models\User; use Illuminate\View\View; +use Livewire\Attributes\Computed; use Livewire\Component; /** @@ -37,7 +37,7 @@ public function form(Schema $schema): Schema ->components([ MarkdownEditor::make('content') ->hiddenLabel() - ->placeholder('O que está acontecendo?') + ->placeholder(__('panel-app::feed.composer.placeholder')) ->toolbarButtons([]) ->required() ->maxLength(5_000), @@ -63,15 +63,11 @@ public function post(): void /** @var array{content: string, images?: list} $state */ $state = $this->form->getState(); - /** @var string $tenantId */ - $tenantId = Filament::getTenant()->getKey(); - /** @var User $user */ $user = auth()->user(); resolve(CreatePost::class)->handle(new CreatePostDTO( userId: $user->id, - tenantId: $tenantId, content: $state['content'], images: $state['images'] ?? [], )); @@ -80,6 +76,15 @@ public function post(): void $this->dispatch('timeline.post-created'); } + #[Computed] + public function avatarUrl(): ?string + { + /** @var User $user */ + $user = auth()->user(); + + return $user->getFirstMediaUrl('avatar') ?: null; + } + public function render(): View { return view('panel-app::livewire.timeline.composer'); diff --git a/app-modules/panel-app/src/Livewire/Timeline/Feed.php b/app-modules/panel-app/src/Livewire/Timeline/Feed.php index cd64d8a36..66e113729 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/Feed.php +++ b/app-modules/panel-app/src/Livewire/Timeline/Feed.php @@ -7,7 +7,6 @@ use He4rt\Activity\Timeline\Queries\TimelineFeed; use He4rt\PanelApp\Livewire\Timeline\Concerns\HasLoadMore; use Illuminate\View\View; -use Livewire\Attributes\Locked; use Livewire\Attributes\On; use Livewire\Component; @@ -15,9 +14,6 @@ final class Feed extends Component { use HasLoadMore; - #[Locked] - public string $tenantId; - #[On(event: 'timeline.post-created')] #[On(event: 'timeline.reply-created')] #[On(event: 'timeline.reply-deleted')] @@ -25,7 +21,7 @@ public function refresh(): void {} public function render(): View { - $items = new TimelineFeed($this->tenantId) + $items = new TimelineFeed() ->builder() ->with(['user', 'postable']) ->withCount('children', 'reactions') diff --git a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php index bd1abfa33..ff0ff6405 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php +++ b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php @@ -26,14 +26,12 @@ public function refresh(): void {} public function togglePin(#[CurrentUser] ?User $user): void { - if (!$user instanceof User) { + if (!config('he4rt.features.timeline_pin') || !$user instanceof User) { return; } $timeline = Timeline::query() - ->where('id', $this->timelineId) - ->where('tenant_id', filament()->getTenant()->getKey()) - ->firstOrFail(); + ->where('id', $this->timelineId)->firstOrFail(); resolve(TogglePinPost::class)->handle($user, $timeline); @@ -43,9 +41,7 @@ public function togglePin(#[CurrentUser] ?User $user): void public function render(): View { $timeline = Timeline::query() - ->where('id', $this->timelineId) - ->where('tenant_id', filament()->getTenant()->getKey()) - ->with([ + ->where('id', $this->timelineId)->with([ 'user', 'postable', 'reactions', diff --git a/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php b/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php index c09b3f24d..2dfe0c854 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php +++ b/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php @@ -13,6 +13,7 @@ use He4rt\Activity\Timeline\DTOs\CreateReplyDTO; use He4rt\Identity\User\Models\User; use Illuminate\View\View; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -70,12 +71,8 @@ public function reply(): void /** @var User $user */ $user = auth()->user(); - /** @var string $tenantId */ - $tenantId = filament()->getTenant()->getKey(); - resolve(CreateReply::class)->handle(new CreateReplyDTO( userId: $user->id, - tenantId: $tenantId, parentTimelineId: $this->timelineId, content: $state['content'], images: $state['images'] ?? [], @@ -85,6 +82,15 @@ public function reply(): void $this->dispatch('timeline.reply-created'); } + #[Computed] + public function avatarUrl(): ?string + { + /** @var User $user */ + $user = auth()->user(); + + return $user->getFirstMediaUrl('avatar') ?: null; + } + public function render(): View { return view('panel-app::livewire.timeline.reply-composer'); diff --git a/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php b/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php index 353c8719e..a4f564c01 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php +++ b/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php @@ -32,9 +32,7 @@ public function deleteReply(string $replyId, #[CurrentUser] ?User $user): void } $reply = Timeline::query() - ->where('id', $replyId) - ->where('tenant_id', filament()->getTenant()->getKey()) - ->firstOrFail(); + ->where('id', $replyId)->firstOrFail(); resolve(DeleteReply::class)->handle($user, $reply); @@ -44,10 +42,8 @@ public function deleteReply(string $replyId, #[CurrentUser] ?User $user): void public function render(): View { $replies = Timeline::query() - ->where('root_id', $this->timelineId) - ->where('tenant_id', filament()->getTenant()->getKey()) - ->whereNotNull('parent_id') - ->with(['user', 'postable']) + ->where('root_id', $this->timelineId)->whereNotNull('parent_id') + ->with(['user.media', 'postable']) ->oldest() ->simplePaginate($this->perPage); diff --git a/app-modules/panel-app/src/Pages/EventPage.php b/app-modules/panel-app/src/Pages/EventPage.php index c5b2f0ad9..56838a4e0 100644 --- a/app-modules/panel-app/src/Pages/EventPage.php +++ b/app-modules/panel-app/src/Pages/EventPage.php @@ -26,7 +26,6 @@ public function mount(string $record): void { $exists = Event::query() ->where('id', $record) - ->where('tenant_id', filament()->getTenant()->getKey()) ->viewableByParticipant() ->exists(); diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php index d007d5310..8b1f07228 100644 --- a/app-modules/panel-app/src/Pages/ProfilePage.php +++ b/app-modules/panel-app/src/Pages/ProfilePage.php @@ -8,6 +8,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; @@ -21,6 +22,7 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; +use Filament\Schemas\JsContent; use Filament\Schemas\Schema; use Filament\Support\Enums\Width; use He4rt\Gamification\Character\Models\Character; @@ -39,12 +41,12 @@ use He4rt\Profile\Models\Skill; use Illuminate\Support\Str; use Livewire\Attributes\Computed; -use Livewire\Attributes\Validate; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; /** * @property-read Schema $form + * @property-read Schema $birthdateForm */ class ProfilePage extends Page { @@ -53,13 +55,8 @@ class ProfilePage extends Page /** @var array|null */ public ?array $data = []; - /** @var TemporaryUploadedFile|null */ - #[Validate(rule: 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048')] - public $avatarUpload; - - /** @var TemporaryUploadedFile|null */ - #[Validate(rule: 'nullable|image|mimes:jpg,jpeg,png,webp|max:4096')] - public $coverUpload; + /** @var array|null */ + public ?array $birthdateData = []; protected static string|null|BackedEnum $navigationIcon = 'heroicon-o-user-circle'; @@ -79,7 +76,6 @@ public function mount(): void $this->form->fill([ 'nickname' => $profile->nickname, - 'birthdate' => $profile->birthdate?->format('Y-m-d'), 'headline' => $profile->headline, 'seniority_level' => $profile->seniority_level, 'years_experience' => $profile->years_experience, @@ -98,6 +94,27 @@ public function mount(): void $profile->preferences->employmentTypes, ), ]); + + $this->birthdateForm->fill([ + 'birthdate' => $profile->birthdate?->format('Y-m-d'), + ]); + } + + public function birthdateForm(Schema $schema): Schema + { + return $schema + ->components([ + DatePicker::make('birthdate') + ->label(__('panel-app::profile.fields.birthdate')) + ->native(condition: false) + ->displayFormat('d/m/Y') + ->format('Y-m-d') + ->minDate(now()->subYears(120)) + ->maxDate(now()), + ]) + // Own state path: fill() replaces the whole path, so sharing `data` with + // the main form wiped headline/seniority/etc. on mount. + ->statePath('birthdateData'); } public function form(Schema $schema): Schema @@ -131,6 +148,7 @@ public function form(Schema $schema): Schema Textarea::make('about') ->label(__('panel-app::profile.fields.about')) ->placeholder(__('panel-app::profile.placeholders.about')) + ->hint(JsContent::make('`${Array.from($state ?? "").length}/500`')) ->maxLength(500) ->rows(4) ->live(onBlur: true) @@ -148,17 +166,28 @@ public function form(Schema $schema): Schema Select::make('skill_id') ->label(__('panel-app::profile.fields.skill')) ->searchable() - ->getSearchResultsUsing(fn (string $search): array => Skill::search($search)) + ->getSearchResultsUsing(fn (string $search, Select $component): array => Skill::search( + $search, + exclude: $this->skillIdsInSiblingRows($component), + )) ->getOptionLabelUsing(fn (?string $value): ?string => $value === null ? null : (Skill::labelsById()[$value] ?? null)) ->optionsLimit(50) ->distinct() - ->required() + ->required(fn (Get $get): bool => filled($get('proficiency'))) ->columnSpan(1), Select::make('proficiency') ->label(__('panel-app::profile.fields.proficiency')) ->options(SkillProficiency::class) - ->required() + ->required(fn (Get $get): bool => filled($get('skill_id'))) + ->live() + ->afterStateUpdated(function (Get $get, Select $component): void { + if (blank($get('skill_id')) || blank($get('proficiency'))) { + return; + } + + $this->appendEmptyRepeaterItemIfLastRow($component); + }) ->columnSpan(1), TextInput::make('years_experience') @@ -226,13 +255,21 @@ public function form(Schema $schema): Schema Select::make('platform') ->label(__('panel-app::profile.fields.platform')) ->options(SocialPlatform::class) - ->required() + ->required(fn (Get $get): bool => filled($get('handle'))) ->columnSpan(1), TextInput::make('handle') ->label(__('panel-app::profile.fields.handle')) ->placeholder(__('panel-app::profile.placeholders.handle')) - ->required() + ->required(fn (Get $get): bool => filled($get('platform'))) + ->live(onBlur: true) + ->afterStateUpdated(function (Get $get, TextInput $component): void { + if (blank($get('platform')) || blank($get('handle'))) { + return; + } + + $this->appendEmptyRepeaterItemIfLastRow($component); + }) ->columnSpan(1), ]), ]) @@ -306,18 +343,18 @@ public function form(Schema $schema): Schema Grid::make(2)->schema([ TextInput::make('company_name') ->label(__('panel-app::profile.fields.company_name')) - ->required() + ->required(fn (Get $get): bool => filled($get('position')) || filled($get('description')) || filled($get('start_date'))) ->maxLength(255) ->columnSpan(1), TextInput::make('position') ->label(__('panel-app::profile.fields.position')) - ->required() + ->required(fn (Get $get): bool => filled($get('company_name')) || filled($get('description')) || filled($get('start_date'))) ->maxLength(255) ->columnSpan(1), ]), Textarea::make('description') ->label(__('panel-app::profile.fields.experience_description')) - ->required() + ->required(fn (Get $get): bool => filled($get('company_name')) || filled($get('position')) || filled($get('start_date'))) ->rows(3) ->maxLength(2_000) ->columnSpanFull(), @@ -328,7 +365,7 @@ public function form(Schema $schema): Schema ->displayFormat('M Y') ->format('Y-m-d') ->maxDate(now()) - ->required() + ->required(fn (Get $get): bool => filled($get('company_name')) || filled($get('position')) || filled($get('description'))) ->columnSpan(1), DatePicker::make('end_date') ->label(__('panel-app::profile.fields.end_date')) @@ -337,7 +374,7 @@ public function form(Schema $schema): Schema ->format('Y-m-d') ->maxDate(now()) ->afterOrEqual('start_date') - ->required(fn (Get $get): bool => !$get('is_currently_working_here')) + ->required(fn (Get $get): bool => !$get('is_currently_working_here') && (filled($get('company_name')) || filled($get('position')) || filled($get('description')) || filled($get('start_date')))) ->hidden(fn (Get $get): bool => (bool) $get('is_currently_working_here')) ->columnSpan(1), ]), @@ -374,6 +411,7 @@ public function form(Schema $schema): Schema public function save(): void { + $birthdateData = $this->birthdateForm->getState(); $formData = $this->form->getState(); $profile = $this->getRecord(); @@ -381,7 +419,7 @@ public function save(): void $dto = UpsertProfileDTO::fromArray([ 'nickname' => $this->data['nickname'] ?? null, - 'birthdate' => $this->data['birthdate'] ?? null, + 'birthdate' => $birthdateData['birthdate'] ?? null, 'about' => $formData['about'] ?? null, 'headline' => $formData['headline'] ?? null, 'seniority_level' => $formData['seniority_level'] ?? null, @@ -412,7 +450,8 @@ public function save(): void resolve(SyncProfileSkills::class)->handle($profile, $this->repeaterToSkills($formData['skills'] ?? [])); - $this->saveMedia(); + $this->dispatch('scroll-to-top'); + $this->form->saveRelationships(); Notification::make() @@ -421,16 +460,93 @@ public function save(): void ->send(); } - public function getRecord(): Profile + public function editAvatarAction(): Action + { + return Action::make('editAvatar') + ->label(__('panel-app::profile.actions.change_avatar')) + ->modalHeading(__('panel-app::profile.actions.change_avatar')) + ->modalSubmitActionLabel(__('panel-app::profile.actions.save_avatar')) + ->modalSubmitAction(fn (Action $action) => $action->color('primary')) + ->schema([ + FileUpload::make('avatar') + ->label(__('panel-app::profile.fields.avatar')) + ->avatar() + ->imageEditor() + ->circleCropper() + ->imageEditorAspectRatioOptions(['1:1']) + ->storeFiles(condition: false) + ->required() + ->maxSize(2_048), + ]) + ->action(function (array $data): void { + $avatar = $data['avatar'] ?? null; + + if (is_string($avatar)) { + $avatar = TemporaryUploadedFile::createFromLivewire($avatar); + } + + if (!$avatar instanceof TemporaryUploadedFile) { + return; + } + + $this->replaceMedia('avatar', $avatar); + + Notification::make() + ->success() + ->title(__('panel-app::profile.notifications.avatar_updated')) + ->send(); + }); + } + + public function editCoverAction(): Action { - $tenantId = filament()->getTenant()?->getKey(); - abort_unless($tenantId, 403); + return Action::make('editCover') + ->label(__('panel-app::profile.actions.change_cover')) + ->modalHeading(__('panel-app::profile.actions.change_cover')) + ->modalSubmitActionLabel(__('panel-app::profile.actions.save_cover')) + ->modalSubmitAction(fn (Action $action) => $action->color('primary')) + ->schema([ + FileUpload::make('cover') + ->label(__('panel-app::profile.fields.cover')) + ->image() + ->panelAspectRatio('3:1') + ->imageEditor() + ->imageAspectRatio('3:1') + ->imageEditorAspectRatioOptions(['3:1']) + ->automaticallyCropImagesToAspectRatio() + ->automaticallyResizeImagesMode('cover') + ->automaticallyResizeImagesToWidth('1800') + ->automaticallyResizeImagesToHeight('600') + ->storeFiles(condition: false) + ->required() + ->maxSize(4_096), + ]) + ->action(function (array $data): void { + $cover = $data['cover'] ?? null; + + if (is_string($cover)) { + $cover = TemporaryUploadedFile::createFromLivewire($cover); + } + + if (!$cover instanceof TemporaryUploadedFile) { + return; + } + $this->replaceMedia('cover', $cover); + + Notification::make() + ->success() + ->title(__('panel-app::profile.notifications.cover_updated')) + ->send(); + }); + } + + public function getRecord(): Profile + { return Profile::query() ->firstOrCreate( [ 'user_id' => auth()->id(), - 'tenant_id' => $tenantId, ], ); } @@ -441,7 +557,6 @@ public function character(): ?Character return Character::query() ->with('badges') ->where('user_id', auth()->id()) - ->where('tenant_id', filament()->getTenant()?->getKey()) ->first(); } @@ -458,13 +573,8 @@ public function initials(): string #[Computed] public function avatarPreviewUrl(): ?string { - if ($this->avatarUpload instanceof TemporaryUploadedFile) { - /** @var string */ - return $this->avatarUpload->temporaryUrl(); - } - /** @var User $user */ - $user = auth()->user(); + $user = auth()->user()->fresh(); return $user->getFirstMediaUrl('avatar') ?: null; } @@ -472,49 +582,31 @@ public function avatarPreviewUrl(): ?string #[Computed] public function coverPreviewUrl(): ?string { - if ($this->coverUpload instanceof TemporaryUploadedFile) { - /** @var string */ - return $this->coverUpload->temporaryUrl(); - } - /** @var User $user */ - $user = auth()->user(); + $user = auth()->user()->fresh(); return $user->getFirstMediaUrl('cover') ?: null; } public function removeAvatar(): void { - $this->avatarUpload = null; auth()->user()->clearMediaCollection('avatar'); } public function removeCover(): void { - $this->coverUpload = null; auth()->user()->clearMediaCollection('cover'); } - private function saveMedia(): void + private function replaceMedia(string $collection, TemporaryUploadedFile $file): void { /** @var User $user */ $user = auth()->user(); - - if ($this->avatarUpload instanceof TemporaryUploadedFile) { - $user->clearMediaCollection('avatar'); - $user->addMedia($this->avatarUpload->getRealPath()) - ->usingFileName(Str::uuid()->toString().'.'.$this->avatarUpload->getClientOriginalExtension()) - ->toMediaCollection('avatar'); - $this->avatarUpload = null; - } - - if ($this->coverUpload instanceof TemporaryUploadedFile) { - $user->clearMediaCollection('cover'); - $user->addMedia($this->coverUpload->getRealPath()) - ->usingFileName(Str::uuid()->toString().'.'.$this->coverUpload->getClientOriginalExtension()) - ->toMediaCollection('cover'); - $this->coverUpload = null; - } + $extension = $file->getClientOriginalExtension() ?: $file->guessExtension() ?: 'jpg'; + $user->clearMediaCollection($collection); + $user->addMedia($file->getRealPath()) + ->usingFileName(Str::uuid()->toString().'.'.$extension) + ->toMediaCollection($collection); } /** @@ -559,10 +651,18 @@ private function repeaterToSocialLinks(array $repeaterData): array /** * @param array $data - * @return array + * @return array|null null tells Filament to skip creating/saving this row */ - private function normalizeWorkExperienceData(array $data): array + private function normalizeWorkExperienceData(array $data): ?array { + $keyFields = ['company_name', 'position', 'description', 'start_date']; + + $hasAnyData = collect($keyFields)->contains(fn (string $field): bool => filled($data[$field] ?? null)); + + if (!$hasAnyData) { + return null; + } + if ($data['is_currently_working_here'] ?? false) { $data['end_date'] = null; } @@ -588,6 +688,82 @@ private function skillsToRepeater(Profile $profile): array return $skills; } + private function appendEmptyRepeaterItemIfLastRow(Select|TextInput $component): void + { + $repeater = $component->getParentRepeater(); + + if (!$repeater instanceof Repeater) { + return; + } + + $items = $repeater->getRawState(); + + if (!is_array($items) || blank($items)) { + return; + } + + $repeaterPath = $repeater->getStatePath(); + $componentPath = $component->getStatePath(); + + if ($repeaterPath === null || $componentPath === null) { + return; + } + + $currentKey = explode('.', mb_substr($componentPath, mb_strlen($repeaterPath) + 1))[0]; + + if ($currentKey !== array_key_last($items)) { + return; + } + + $newUuid = $repeater->generateUuid(); + + if ($newUuid) { + $items[$newUuid] = []; + } else { + $items[] = []; + } + + $repeater->rawState($items); + + $childSchema = $repeater->getChildSchema($newUuid ?? array_key_last($items)); + + if ($childSchema instanceof Schema) { + $childSchema->fill(); + } + + $repeater->collapsed(condition: false, shouldMakeComponentCollapsible: false); + $repeater->callAfterStateUpdated(); + } + + /** + * Skill ids already chosen in the other rows of the skills repeater, so the + * search can omit them and each skill is only pickable once. + * + * @return list + */ + private function skillIdsInSiblingRows(Select $component): array + { + $repeater = $component->getParentRepeater(); + + if (!$repeater instanceof Repeater) { + return []; + } + + $rows = $repeater->getRawState(); + + if (!is_array($rows)) { + return []; + } + + return array_values( + collect($rows) + ->pluck('skill_id') + ->reject(fn (mixed $id): bool => blank($id) || $id === $component->getState()) + ->map(fn (mixed $id): string => (string) $id) + ->all() + ); + } + /** * @param array> $repeaterData * @return list diff --git a/app-modules/panel-app/src/Pages/ThreadPage.php b/app-modules/panel-app/src/Pages/ThreadPage.php index 49a69f3c4..8bb274c73 100644 --- a/app-modules/panel-app/src/Pages/ThreadPage.php +++ b/app-modules/panel-app/src/Pages/ThreadPage.php @@ -25,9 +25,7 @@ class ThreadPage extends Page public function mount(string $record): void { $exists = Timeline::query() - ->where('id', $record) - ->where('tenant_id', filament()->getTenant()->getKey()) - ->whereNull('parent_id') + ->where('id', $record)->whereNull('parent_id') ->exists(); abort_unless($exists, 404); diff --git a/app-modules/panel-app/src/PanelAppServiceProvider.php b/app-modules/panel-app/src/PanelAppServiceProvider.php index 8a0da2def..787975310 100644 --- a/app-modules/panel-app/src/PanelAppServiceProvider.php +++ b/app-modules/panel-app/src/PanelAppServiceProvider.php @@ -4,6 +4,8 @@ namespace He4rt\PanelApp; +use Filament\Support\Assets\Js; +use Filament\Support\Facades\FilamentAsset; use He4rt\PanelApp\Livewire\Events\EventDetail; use He4rt\PanelApp\Livewire\Events\EventsList; use He4rt\PanelApp\Livewire\Events\MyEventsList; @@ -29,6 +31,10 @@ public function boot(): void Livewire::component('my-events-list', MyEventsList::class); Livewire::component('event-detail', EventDetail::class); Livewire::component('numeric-code-check-in', NumericCodeCheckIn::class); + FilamentAsset::register([ + Js::make('last-auth-provider', __DIR__.'/../resources/js/last-auth-provider.js') + ->loadedOnRequest(), + ], package: 'he4rt/panel-app'); Livewire::component('timeline-composer', Composer::class); Livewire::component('timeline-feed', Feed::class); diff --git a/app-modules/panel-app/tests/Feature/Auth/LastAuthProviderTest.php b/app-modules/panel-app/tests/Feature/Auth/LastAuthProviderTest.php new file mode 100644 index 000000000..d5de5173b --- /dev/null +++ b/app-modules/panel-app/tests/Feature/Auth/LastAuthProviderTest.php @@ -0,0 +1,39 @@ +get(Filament::getPanel('app')->getLoginUrl()) + ->assertSuccessful() + ->assertSee("window.localStorage.getItem('lastAuthProvider')", escape: false) + ->assertDontSee('last-auth-provider', escape: false); +}); + +test('oauth callback landing loads the writer asset for a supported provider', function (): void { + $this->actingAs(User::factory()->create()); + + $this->get(url()->query(TimelinePage::getUrl(), [ + 'oauth_provider' => 'github', + ])) + ->assertSuccessful() + ->assertSee('last-auth-provider', escape: false); +}); + +test('dashboard does not load the writer asset without a supported oauth marker', function (array $query): void { + $this->actingAs(User::factory()->create()); + + $this->get(url()->query(TimelinePage::getUrl(), $query)) + ->assertSuccessful() + ->assertDontSee('last-auth-provider', escape: false); +})->with([ + 'without marker' => [[]], + 'unsupported provider' => [['oauth_provider' => 'devto']], +]); diff --git a/app-modules/panel-app/tests/Feature/ConnectionHubTest.php b/app-modules/panel-app/tests/Feature/ConnectionHubTest.php new file mode 100644 index 000000000..a8dc49331 --- /dev/null +++ b/app-modules/panel-app/tests/Feature/ConnectionHubTest.php @@ -0,0 +1,25 @@ +create(); + $mergeTarget = User::factory()->create(); + + $this->actingAs($currentUser); + + session()->put('oauth_merge_pending', [ + 'conflicting_user_id' => $mergeTarget->id, + ]); + + livewire(ConnectionHub::class) + ->assertSet('showMergeModal', value: true) + ->assertSeeHtml('role="dialog"') + ->assertSeeHtml('aria-modal="true"') + ->assertSeeHtml('@keydown.escape.window="$wire.cancelMerge()"'); +}); diff --git a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php index 75e5bd133..9828b2565 100644 --- a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php +++ b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php @@ -7,7 +7,6 @@ use He4rt\Events\Enrollment\Models\Enrollment; use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\PanelApp\Livewire\Events\EventDetail; use He4rt\PanelApp\Pages\EventPage; @@ -16,13 +15,10 @@ beforeEach(function (): void { $this->user = User::factory()->create(); - $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); - $this->tenant->members()->attach($this->user); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('app')); - Filament::setTenant($this->tenant); $this->schema = [ ['key' => 'why_join', 'type' => 'text', 'label' => 'Why do you want to join?', 'required' => true], @@ -32,7 +28,6 @@ $this->event = Event::factory() ->published() ->upcoming() - ->for($this->tenant) ->has(EnrollmentPolicy::factory()->application($this->schema), 'enrollmentPolicy') ->create(['title' => 'He4rt Conf Application']); }); diff --git a/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php index fcd5529f6..102a92072 100644 --- a/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php +++ b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php @@ -8,7 +8,6 @@ use He4rt\Events\Enrollment\Models\Enrollment; use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\PanelApp\Livewire\Events\NumericCodeCheckIn; use Illuminate\Support\Facades\RateLimiter; @@ -17,18 +16,14 @@ beforeEach(function (): void { $this->user = User::factory()->create(); - $this->tenant = Tenant::factory()->create(['slug' => 'numeric-code-test-tenant']); - $this->tenant->members()->attach($this->user); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('app')); - Filament::setTenant($this->tenant); $this->event = Event::factory() ->published() ->upcoming() - ->for($this->tenant) ->has(EnrollmentPolicy::factory()->rsvp()->state([ 'check_in_method' => CheckInMethod::NumericCode, ]), 'enrollmentPolicy') diff --git a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php index 1bcc5f39f..93c18e8c0 100644 --- a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php +++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php @@ -8,7 +8,6 @@ use He4rt\Events\Enrollment\Models\EnrollmentPolicy; use He4rt\Events\Event\Enums\EventStatus; use He4rt\Events\Event\Models\Event; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\PanelApp\Livewire\Events\EventDetail; use He4rt\PanelApp\Livewire\Events\MyEventsList; @@ -20,18 +19,14 @@ beforeEach(function (): void { $this->user = User::factory()->create(); - $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); - $this->tenant->members()->attach($this->user); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('app')); - Filament::setTenant($this->tenant); $this->event = Event::factory() ->published() ->upcoming() - ->for($this->tenant) ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') ->create(['title' => 'He4rt Meetup RSVP']); }); @@ -79,16 +74,13 @@ expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(1); }); -test('event page returns 404 for event from another tenant', function (): void { - $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']); - $otherEvent = Event::factory() - ->published() +test('event page returns 404 for an event not viewable by participants', function (): void { + $draftEvent = Event::factory() ->upcoming() - ->for($otherTenant) ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') - ->create(); + ->create(['status' => EventStatus::Draft]); - $this->get(EventPage::getUrl(['record' => $otherEvent->id])) + $this->get(EventPage::getUrl(['record' => $draftEvent->id])) ->assertNotFound(); }); @@ -195,7 +187,6 @@ $pastEvent = Event::factory() ->published() ->past() - ->for($this->tenant) ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') ->create(['title' => 'Past Meetup']); diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php index 60ff35996..0014d8e38 100644 --- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use Filament\Facades\Filament; -use He4rt\Identity\Tenant\Models\Tenant; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Schemas\JsContent; use He4rt\Identity\User\Models\User; use He4rt\PanelApp\Pages\ProfilePage; use He4rt\Profile\Enums\EmploymentType; @@ -12,13 +14,16 @@ use He4rt\Profile\Enums\StartAvailability; use He4rt\Profile\Models\Profile; use He4rt\Profile\Models\Skill; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use function Pest\Livewire\livewire; beforeEach(function (): void { Cache::flush(); + Storage::fake(config()->string('filesystems.default')); Http::fake([ 'world.bmbc.cloud/api/countries*' => Http::response([ @@ -40,17 +45,13 @@ ]); $this->user = User::factory()->create(); - $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); - $this->tenant->members()->attach($this->user); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('app')); - Filament::setTenant($this->tenant); $this->profile = Profile::query() ->where('user_id', $this->user->id) - ->where('tenant_id', $this->tenant->id) ->first(); }); @@ -94,6 +95,20 @@ ->and($this->profile->about)->toBe('Dev PHP apaixonado por Laravel'); }); +test('profile page saves birthdate from date picker', function (): void { + livewire(ProfilePage::class) + ->fillForm([ + 'birthdate' => '1996-02-15', + ], 'birthdateForm') + ->call('save') + ->assertHasNoFormErrors([], 'birthdateForm') + ->assertNotified(); + + $this->profile->refresh(); + + expect($this->profile->birthdate?->format('Y-m-d'))->toBe('1996-02-15'); +}); + test('profile page saves social links from repeater', function (): void { livewire(ProfilePage::class) ->fillForm([ @@ -137,6 +152,34 @@ ->and($pivot->years_experience)->toBe(5); }); +test('skills repeater hides skills already chosen in other rows', function (): void { + $php = Skill::query()->where('slug', 'php')->sole(); + + $component = livewire(ProfilePage::class) + ->fillForm([ + 'skills' => [ + ['skill_id' => $php->id, 'proficiency' => 'advanced', 'years_experience' => 5], + ['skill_id' => null, 'proficiency' => null, 'years_experience' => null], + ], + ]); + + $instance = $component->instance(); + [$firstRow, $secondRow] = array_keys($instance->data['skills']); + + $searchIn = static function (int|string $rowKey) use ($instance, $php): array { + /** @var Select $select */ + $select = $instance->form->getComponentByStatePath( + sprintf('data.skills.%s.skill_id', $rowKey), + withAbsoluteStatePath: true, + ); + + return array_keys($select->getSearchResults($php->slug)); + }; + + expect($searchIn($secondRow))->not->toContain($php->id) + ->and($searchIn($firstRow))->toContain($php->id); +}); + test('profile page saves availability toggle', function (): void { livewire(ProfilePage::class) ->fillForm([ @@ -161,6 +204,21 @@ ->assertHasFormErrors(['about']); }); +test('profile page shows about character counter in real time', function (): void { + livewire(ProfilePage::class) + ->fillForm([ + 'about' => 'abc'."\u{1F600}".'def', + ]) + ->assertFormFieldExists('about', function (Textarea $field): bool { + expect($field->getHint())->toBeInstanceOf(JsContent::class) + ->and($field->getHint()->toHtml())->toContain('Array.from($state') + ->toContain('500') + ->and($field->getStateBindingModifiers())->toBe(['live', 'blur']); + + return true; + }); +}); + test('profile page validates headline max length', function (): void { livewire(ProfilePage::class) ->fillForm([ @@ -259,3 +317,31 @@ expect($experience->is_currently_working_here)->toBeTrue() ->and($experience->end_date)->toBeNull(); }); + +test('profile page uploads avatar through action modal', function (): void { + livewire(ProfilePage::class) + ->callAction('editAvatar', [ + 'avatar' => UploadedFile::fake()->image('avatar.jpg', 1_200, 1_200), + ]) + ->assertHasNoActionErrors() + ->assertNotified(); + + $media = $this->user->fresh()->getFirstMedia('avatar'); + + expect($media)->not->toBeNull() + ->and($media?->collection_name)->toBe('avatar'); +}); + +test('profile page uploads cover through action modal', function (): void { + livewire(ProfilePage::class) + ->callAction('editCover', [ + 'cover' => UploadedFile::fake()->image('cover.jpg', 1_800, 600), + ]) + ->assertHasNoActionErrors() + ->assertNotified(); + + $media = $this->user->fresh()->getFirstMedia('cover'); + + expect($media)->not->toBeNull() + ->and($media?->collection_name)->toBe('cover'); +}); diff --git a/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php b/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php index d4bc4f4c2..1441b1eb1 100644 --- a/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php +++ b/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php @@ -5,31 +5,27 @@ use Filament\Facades\Filament; use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Timeline; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\PanelApp\Livewire\Timeline\PostShow; use He4rt\PanelApp\Livewire\Timeline\ReplyComposer; use He4rt\PanelApp\Livewire\Timeline\ThreadReplies; use He4rt\PanelApp\Pages\ThreadPage; -use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use function Pest\Livewire\livewire; beforeEach(function (): void { $this->user = User::factory()->create(); - $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); - $this->tenant->members()->attach($this->user); $this->actingAs($this->user); Filament::setCurrentPanel(Filament::getPanel('app')); - Filament::setTenant($this->tenant); $postEntry = PostEntry::factory()->create(['content' => 'Root post content']); $this->rootPost = Timeline::factory() ->for($this->user) ->create([ - 'tenant_id' => $this->tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $postEntry->id, ]); @@ -61,7 +57,6 @@ $entry1 = PostEntry::factory()->create(['content' => 'First reply']); Timeline::factory()->for($replier)->create([ - 'tenant_id' => $this->tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $entry1->id, 'root_id' => $this->rootPost->id, @@ -71,7 +66,6 @@ $entry2 = PostEntry::factory()->create(['content' => 'Second reply']); Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $this->tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $entry2->id, 'root_id' => $this->rootPost->id, @@ -85,11 +79,28 @@ ->assertSeeInOrder(['First reply', 'Second reply']); }); +test('thread replies shows the author avatar when one exists', function (): void { + Storage::fake(config()->string('filesystems.default')); + + $replier = User::factory()->create(['name' => 'Replier With Avatar']); + $replier->addMedia(UploadedFile::fake()->image('avatar.jpg'))->toMediaCollection('avatar'); + + $entry = PostEntry::factory()->create(['content' => 'Reply with avatar']); + Timeline::factory()->for($replier)->create([ + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => $entry->id, + 'root_id' => $this->rootPost->id, + 'parent_id' => $this->rootPost->id, + ]); + + livewire(ThreadReplies::class, ['timelineId' => $this->rootPost->id]) + ->assertSee($replier->getFirstMediaUrl('avatar'), escape: false); +}); + test('post renders without crashing when its author was deleted', function (): void { $ghost = User::factory()->create(); $entry = PostEntry::factory()->create(['content' => 'Orphaned post content']); $orphan = Timeline::factory()->for($ghost)->create([ - 'tenant_id' => $this->tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $entry->id, ]); @@ -102,71 +113,9 @@ ->assertSee('Orphaned post content'); }); -// --- Critical: Tenant isolation --- - -test('thread page returns 404 for post from another tenant', function (): void { - $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']); - $otherEntry = PostEntry::factory()->create(['content' => 'Other tenant post']); - $otherPost = Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $otherTenant->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => $otherEntry->id, - ]); - - $this->get(ThreadPage::getUrl(['record' => $otherPost->id])) - ->assertNotFound(); -}); - -test('thread replies does not show replies from another tenant', function (): void { - $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']); - $otherEntry = PostEntry::factory()->create(['content' => 'Other tenant post']); - $otherPost = Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $otherTenant->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => $otherEntry->id, - ]); - - $replyEntry = PostEntry::factory()->create(['content' => 'Cross-tenant reply']); - Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $otherTenant->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => $replyEntry->id, - 'root_id' => $otherPost->id, - 'parent_id' => $otherPost->id, - ]); - - livewire(ThreadReplies::class, ['timelineId' => $otherPost->id]) - ->assertDontSee('Cross-tenant reply'); -}); - -test('cannot delete reply from another tenant', function (): void { - $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']); - $otherEntry = PostEntry::factory()->create(['content' => 'Other post']); - $otherPost = Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $otherTenant->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => $otherEntry->id, - ]); - - $replyEntry = PostEntry::factory()->create(['content' => 'Other reply']); - $otherReply = Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $otherTenant->id, - 'postable_type' => (new PostEntry)->getMorphClass(), - 'postable_id' => $replyEntry->id, - 'root_id' => $otherPost->id, - 'parent_id' => $otherPost->id, - ]); - - $this->expectException(ModelNotFoundException::class); - - livewire(ThreadReplies::class, ['timelineId' => $otherPost->id]) - ->call('deleteReply', $otherReply->id); -}); - test('owner can delete their own reply', function (): void { $replyEntry = PostEntry::factory()->create(['content' => 'My reply']); $reply = Timeline::factory()->for($this->user)->create([ - 'tenant_id' => $this->tenant->id, 'postable_type' => (new PostEntry)->getMorphClass(), 'postable_id' => $replyEntry->id, 'root_id' => $this->rootPost->id, diff --git a/app-modules/portal/resources/views/components/navbar.blade.php b/app-modules/portal/resources/views/components/navbar.blade.php index cb62c74f1..c0bb22667 100644 --- a/app-modules/portal/resources/views/components/navbar.blade.php +++ b/app-modules/portal/resources/views/components/navbar.blade.php @@ -75,6 +75,17 @@ class="absolute -bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-[v iconOnly aria-label="Redes sociais" /> + Área do Usuário +
+ Compre na Loja + Área do Usuário {{ $meta['people'] }} pessoas somaram {{ $meta['total'] }} interações - em {{ number_format($meta['changed_files'], 0, ',', '.') }} arquivos tocados. + em {{ $meta['repos'] }} @choice('repositório|repositórios', $meta['repos']).

diff --git a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php b/app-modules/portal/resources/views/components/retro/slides/repo.blade.php index 305543d87..9eedb7d46 100644 --- a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/repo.blade.php @@ -44,11 +44,9 @@ class="mini" @endif
- @forelse ($repo['prs'] as $pr) + @foreach ($repo['prs'] as $pr)
- @empty -

Sem PRs nesse recorte — a atividade veio de reviews, issues e comentários.

- @endforelse + @endforeach
diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 770b5e59e..9fe0df7c8 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -6,7 +6,6 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Retrospective\CommunityRetrospective; @@ -21,8 +20,6 @@ #[Title(content: 'Quem fez a He4rt bater')] final class CommunityRetrospectivePage extends Component { - public string $tenantId; - #[Url] public ?string $since = null; @@ -55,16 +52,6 @@ final class CommunityRetrospectivePage extends Component #[Url] public bool $showHighlights = true; - public function mount(?string $tenantSlug = null): void - { - $slug = $tenantSlug ?? config()->string('he4rt.main_tenant'); - - /** @var Tenant $tenant */ - $tenant = Tenant::query()->where('slug', $slug)->firstOrFail(); - - $this->tenantId = $tenant->id; - } - public function toggleType(string $type): void { $this->types = $this->toggleAware($this->types, $this->allTypes(), $type); @@ -97,7 +84,7 @@ public function render(): View : CarbonImmutable::now(); $filters = RetrospectiveFilters::make($since, $until, $this->repos, $this->types, $this->outcome, $this->person, $this->hideBots, $this->sort); - $data = new CommunityRetrospective($this->tenantId, $filters)->build(); + $data = new CommunityRetrospective($filters)->build(); $repoOptions = collect($this->allRepos()) ->mapWithKeys(fn (string $repo): array => [$repo => (string) str($repo)->afterLast('/')]) @@ -141,14 +128,13 @@ private function toggleAware(array $selected, array $all, string $value): array } /** - * Data da contribuição mais antiga dentro do escopo atual (tenant + repos + * Data da contribuição mais antiga dentro do escopo atual (repos * selecionados), para o preset "tudo" cobrir o histórico real do que está * sendo apresentado. Sem registros, cai no mesmo default semanal do render(). */ private function firstContributionDate(): string { $first = GithubContribution::query() - ->where('tenant_id', $this->tenantId) ->when($this->repos !== [], fn ($query) => $query->whereIn('repo', $this->repos)) ->min('occurred_at'); @@ -164,7 +150,6 @@ private function allRepos(): array { /** @var list $repos */ $repos = GithubContribution::query() - ->where('tenant_id', $this->tenantId) ->distinct() ->orderBy('repo') ->pluck('repo') diff --git a/app-modules/portal/src/Livewire/HeroSection.php b/app-modules/portal/src/Livewire/HeroSection.php index 02042dc45..e44252ce2 100644 --- a/app-modules/portal/src/Livewire/HeroSection.php +++ b/app-modules/portal/src/Livewire/HeroSection.php @@ -78,22 +78,15 @@ private function fetchAvatars(): array return []; } - /** @param array $metadata */ - $githubHandle = static function (array $metadata): ?string { - $handle = $metadata['username'] ?? $metadata['name'] ?? null; - - return is_string($handle) ? $handle : null; - }; - return ExternalIdentity::query() ->where('provider', IdentityProvider::GitHub) ->whereIn('model_id', $activeUserIds) + ->whereNotNull('external_account_id') + ->whereRaw("external_account_id ~ '^[0-9]+$'") ->inRandomOrder() ->limit(10) - ->pluck('metadata') - ->map(fn (array $metadata) => $githubHandle($metadata)) - ->filter() - ->map(fn (string $handle) => sprintf('https://github.com/%s.png', $handle)) + ->pluck('external_account_id') + ->map(fn (string $id) => sprintf('https://avatars.githubusercontent.com/u/%s?v=4', $id)) ->values() ->all(); } diff --git a/app-modules/portal/src/Livewire/SocialLinksPage.php b/app-modules/portal/src/Livewire/SocialLinksPage.php index ce3f1064c..c4d4af69a 100644 --- a/app-modules/portal/src/Livewire/SocialLinksPage.php +++ b/app-modules/portal/src/Livewire/SocialLinksPage.php @@ -6,6 +6,7 @@ use He4rt\Portal\DTOs\SocialLink; use Illuminate\Contracts\View\View; +use InvalidArgumentException; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -19,14 +20,23 @@ final class SocialLinksPage extends Component */ public static function links(): array { - return [ - new SocialLink('Discord', 'https://discord.gg/invite/he4rt', 'fab-discord', '#5865F2'), - new SocialLink('X (Twitter)', 'https://x.com/He4rtDevs', 'fab-x-twitter', '#0F172A', '#FFFFFF'), - new SocialLink('LinkedIn', 'https://www.linkedin.com/company/he4rt/', 'fab-linkedin', '#0A66C2'), - new SocialLink('WhatsApp', 'https://chat.whatsapp.com/EBKjYxIodpe1x5LLExbTzK', 'fab-whatsapp', '#25D366'), - new SocialLink('Instagram', 'https://www.instagram.com/heartdevs/', 'fab-instagram', '#E4405F'), - new SocialLink('GitHub', 'https://github.com/he4rt', 'fab-github', '#111827', '#FFFFFF'), - ]; + $links = []; + + foreach (config()->array('he4rt.social_media') as $link) { + if (!is_array($link)) { + continue; + } + + $links[] = new SocialLink( + label: self::field($link, 'label'), + url: self::field($link, 'url'), + icon: self::field($link, 'icon'), + accent: self::field($link, 'accent'), + accentDark: isset($link['accent_dark']) ? self::field($link, 'accent_dark') : null, + ); + } + + return $links; } public function render(): View @@ -35,4 +45,16 @@ public function render(): View 'links' => self::links(), ]); } + + /** + * @param array $link + */ + private static function field(array $link, string $key): string + { + $value = $link[$key] ?? null; + + throw_unless(is_string($value), InvalidArgumentException::class, sprintf("Configuração he4rt.social_media inválida: '%s' deve ser uma string.", $key)); + + return $value; + } } diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 01b9430dc..2bcdc9d33 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -21,7 +21,6 @@ public function boot(): void Route::get('/', Homepage::class); Route::get('/redes', SocialLinksPage::class)->name('social-links'); Route::get('/comunidade/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective'); - Route::get('/comunidade/{tenantSlug}/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective.tenant'); Livewire::component('hero-section', HeroSection::class); } diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index af26f16c9..683eb2604 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -17,7 +17,6 @@ final readonly class CommunityRetrospective { public function __construct( - private string $tenantId, private RetrospectiveFilters $filters, ) {} @@ -34,7 +33,6 @@ public function build(): array { /** @var Collection $contributions */ $contributions = GithubContribution::query() - ->where('tenant_id', $this->tenantId) ->whereBetween('occurred_at', [$this->filters->since, $this->filters->until]) ->get() ->when( @@ -65,6 +63,8 @@ public function build(): array ->values() ->all(); + $repos = $this->repos($contributions); + return [ 'period' => ['since' => $this->filters->since->toDateString(), 'until' => $this->filters->until->toDateString()], 'meta' => [ @@ -80,10 +80,12 @@ public function build(): array 'additions' => $this->sumMeta($contributions, 'additions'), 'deletions' => $this->sumMeta($contributions, 'deletions'), 'changed_files' => $this->sumMeta($contributions, 'changed_files'), + // Repos exibidos = só os com PR no recorte (mesmo universo dos cards). + 'repos' => count($repos), 'total' => $contributions->count(), ], 'people' => $people, - 'repos' => $this->repos($contributions), + 'repos' => $repos, 'highlights' => $this->highlights($contributions), ]; } @@ -271,6 +273,10 @@ private function repos(Collection $contributions): array ], ]; }) + // Só entram na retrospectiva repos com ao menos 1 PR no recorte; + // atividade só de review/issue/comentário some do card (mas segue + // contando em meta/people/highlights). + ->filter(fn (array $repo): bool => $repo['metrics']['prs'] > 0) ->values() ->all()); } diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index ec05b89a0..38bc9b70f 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -3,20 +3,14 @@ declare(strict_types=1); use Carbon\CarbonImmutable; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Livewire\CommunityRetrospectivePage; use function Pest\Livewire\livewire; -beforeEach(function (): void { - config(['he4rt.main_tenant' => 'he4rt']); - $this->tenant = Tenant::factory()->create(['slug' => 'he4rt']); -}); - it('mostra os contribuidores do período informado', function (): void { - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], ]); @@ -30,7 +24,7 @@ it('usa a janela padrão (segunda passada → hoje) quando sem parâmetros', function (): void { $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', ]); @@ -40,16 +34,12 @@ ->assertSee('joao'); }); -it('responde na rota pública /comunidade/retrospectiva (tenant principal)', function (): void { +it('responde na rota pública /comunidade/retrospectiva', function (): void { test()->get('/comunidade/retrospectiva')->assertOk(); }); -it('responde na rota com slug /comunidade/{tenant}/retrospectiva', function (): void { - test()->get('/comunidade/he4rt/retrospectiva')->assertOk(); -}); - it('inclui e marca contribuidor cujo único PR foi fechado sem merge', function (): void { - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'rejeitada', 'actor_id' => 99, 'type' => ContributionType::Pr, 'external_ref' => 'pr:5', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false], ]); @@ -61,7 +51,7 @@ }); it('não renderiza o chrome do portal (sem navbar)', function (): void { - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'alguem', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], ]); @@ -84,7 +74,7 @@ }); it('filtra por tipo ao alternar um tipo de contribuição', function (): void { - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'soreview', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02', ]); @@ -104,11 +94,11 @@ it('preset "tudo" ancora o período na primeira contribuição e traz o histórico inteiro', function (): void { $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'pioneira', 'actor_id' => 1, 'type' => ContributionType::Commit, 'external_ref' => 'commit:abc', 'occurred_at' => '2020-03-30 02:13:45', ]); - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'actor_login' => 'recente', 'actor_id' => 2, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', ]); @@ -123,11 +113,11 @@ it('preset "tudo" com repos filtrados ancora na 1ª contribuição daqueles repos', function (): void { $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'repo' => 'he4rt/antigo', 'actor_login' => 'veterano', 'actor_id' => 1, 'type' => ContributionType::Commit, 'external_ref' => 'commit:old', 'occurred_at' => '2018-01-01 00:00:00', ]); - GithubContribution::factory()->for($this->tenant)->create([ + GithubContribution::factory()->create([ 'repo' => 'he4rt/4noobs', 'actor_login' => 'pioneira', 'actor_id' => 2, 'type' => ContributionType::Commit, 'external_ref' => 'commit:abc', 'occurred_at' => '2020-03-30 02:13:45', ]); diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index 3e536350d..83018a4c3 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Carbon\CarbonImmutable; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Retrospective\CommunityRetrospective; @@ -11,11 +10,9 @@ use Illuminate\Support\Facades\Blade; beforeEach(function (): void { - $this->tenant = Tenant::factory()->create(); $this->since = CarbonImmutable::parse('2026-06-01 00:00:00'); $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); $this->build = fn (?RetrospectiveFilters $filters = null): array => new CommunityRetrospective( - $this->tenant->id, $filters ?? RetrospectiveFilters::period($this->since, $this->until), )->build(); }); @@ -23,15 +20,15 @@ /** * @param array $attributes */ -function contribution(Tenant $tenant, array $attributes): GithubContribution +function contribution(array $attributes): GithubContribution { - return GithubContribution::factory()->for($tenant)->create($attributes); + return GithubContribution::factory()->create($attributes); } it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution($this->tenant, ['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); - contribution($this->tenant, ['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); + contribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); + contribution(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); $data = ($this->build)(); @@ -45,18 +42,8 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('exclui bots do ranking', function (): void { - contribution($this->tenant, ['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - - $data = ($this->build)(); - - expect($data['meta']['people'])->toBe(1) - ->and($data['people'][0]['login'])->toBe('maria'); -}); - -it('isola por tenant: ignora contribuições de outra comunidade', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution(Tenant::factory()->create(), ['actor_login' => 'estranho', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); $data = ($this->build)(); @@ -65,9 +52,9 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('inclui PRs fechados sem merge no total, distinguindo por desfecho', function (): void { - contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); - contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); - contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); + contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); + contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); $data = ($this->build)(); @@ -83,8 +70,8 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('conta comentários de issue e de review separadamente', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Comment, 'external_ref' => 'comment:1', 'occurred_at' => '2026-06-02']); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::ReviewComment, 'external_ref' => 'review_comment:1', 'occurred_at' => '2026-06-02']); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Comment, 'external_ref' => 'comment:1', 'occurred_at' => '2026-06-02']); + contribution(['actor_login' => 'maria', 'type' => ContributionType::ReviewComment, 'external_ref' => 'review_comment:1', 'occurred_at' => '2026-06-02']); $data = ($this->build)(); $maria = collect($data['people'])->firstWhere('login', 'maria'); @@ -97,8 +84,8 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('respeita a janela de período pelo occurred_at', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); $data = ($this->build)(); @@ -107,9 +94,9 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('soma additions/deletions/changed_files de PRs em meta e por pessoa', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 100, 'deletions' => 20, 'changed_files' => 5]]); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 30, 'deletions' => 4, 'changed_files' => 2]]); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 100, 'deletions' => 20, 'changed_files' => 5]]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 30, 'deletions' => 4, 'changed_files' => 2]]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); $data = ($this->build)(); $maria = collect($data['people'])->firstWhere('login', 'maria'); @@ -122,8 +109,8 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('expõe refs de PR e issue por pessoa para os chips de atividade', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:290', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'feat: x', 'url' => 'https://github.com/he4rt/heartdevs.com/pull/290']]); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:12', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'bug: y', 'url' => 'https://github.com/he4rt/heartdevs.com/issues/12']]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:290', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'feat: x', 'url' => 'https://github.com/he4rt/heartdevs.com/pull/290']]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:12', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'bug: y', 'url' => 'https://github.com/he4rt/heartdevs.com/issues/12']]); $data = ($this->build)(); $maria = collect($data['people'])->firstWhere('login', 'maria'); @@ -136,9 +123,9 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('ordena os refs de PR do mais recente para o mais antigo (para o "últimos N")', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-01', 'metadata' => ['title' => 'antigo', 'url' => 'u1', 'state' => 'merged']]); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-05', 'metadata' => ['title' => 'recente', 'url' => 'u2', 'state' => 'open']]); - contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'meio', 'url' => 'u3', 'state' => 'open']]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-01', 'metadata' => ['title' => 'antigo', 'url' => 'u1', 'state' => 'merged']]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-05', 'metadata' => ['title' => 'recente', 'url' => 'u2', 'state' => 'open']]); + contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'meio', 'url' => 'u3', 'state' => 'open']]); $maria = collect(($this->build)()['people'])->firstWhere('login', 'maria'); @@ -192,8 +179,8 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('agrupa PRs por repositório e lista destaques por linhas mudadas', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'grande', 'url' => 'u1', 'additions' => 500, 'deletions' => 100, 'changed_files' => 10]]); - contribution($this->tenant, ['actor_login' => 'joao', 'repo' => 'he4rt/bot', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'title' => 'pequeno', 'url' => 'u2', 'additions' => 10, 'deletions' => 2, 'changed_files' => 1]]); + contribution(['actor_login' => 'maria', 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'grande', 'url' => 'u1', 'additions' => 500, 'deletions' => 100, 'changed_files' => 10]]); + contribution(['actor_login' => 'joao', 'repo' => 'he4rt/bot', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'title' => 'pequeno', 'url' => 'u2', 'additions' => 10, 'deletions' => 2, 'changed_files' => 1]]); $data = ($this->build)(); @@ -205,10 +192,32 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution ->and($data['highlights'][0]['repo'])->toBe('he4rt/heartdevs.com'); }); +it('esconde da lista repos sem PR no recorte mas mantém suas contribuições nas stats', function (): void { + contribution(['actor_login' => 'maria', 'repo' => 'he4rt/com-pr', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 't', 'url' => 'u', 'additions' => 5, 'deletions' => 1, 'changed_files' => 1]]); + contribution(['actor_login' => 'joao', 'repo' => 'he4rt/so-review', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + + $data = ($this->build)(); + + expect(collect($data['repos'])->pluck('full_name')->all())->toBe(['he4rt/com-pr']) + ->and($data['meta']['reviews'])->toBe(1) // review segue contando nas stats gerais + ->and($data['meta']['repos'])->toBe(1); // panorama conta só repos com PR +}); + +it('o panorama pluraliza repositórios conforme a contagem', function (int $repos, string $expected): void { + $meta = ['people' => 1, 'total' => 1, 'prs' => 1, 'prs_merged' => 1, 'prs_unmerged' => 0, 'reviews' => 0, 'issues' => 0, 'comments' => 0, 'review_comments' => 0, 'commits' => 0, 'additions' => 1, 'deletions' => 0, 'changed_files' => 1, 'repos' => $repos]; + + $html = Blade::render('', ['meta' => $meta]); + + expect($html)->toContain($expected); +})->with([ + 'singular' => [1, 'em 1 repositório.'], + 'plural' => [3, 'em 3 repositórios.'], +]); + it('aplica filtros de tipo, repo, desfecho e pessoa', function (): void { - contribution($this->tenant, ['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true]]); - contribution($this->tenant, ['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution($this->tenant, ['actor_login' => 'joao', 'repo' => 'he4rt/b', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + contribution(['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true]]); + contribution(['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution(['actor_login' => 'joao', 'repo' => 'he4rt/b', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); $filters = RetrospectiveFilters::make($this->since, $this->until, repos: ['he4rt/a'], types: ['pr'], outcome: 'merged', person: 'maria'); $data = ($this->build)($filters); @@ -220,7 +229,7 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution }); it('keeps only truly-closed PRs under outcome=closed: !(A ∧ ¬B) ≡ ¬A ∨ B', function (string $state, bool $merged, bool $shouldKeep): void { - contribution($this->tenant, [ + contribution([ 'actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', @@ -242,8 +251,8 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution ]); it('ordena o ranking por linhas quando sort=lines', function (): void { - contribution($this->tenant, ['actor_login' => 'poucas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 10, 'deletions' => 0]]); - contribution($this->tenant, ['actor_login' => 'muitas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 900, 'deletions' => 50]]); + contribution(['actor_login' => 'poucas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 10, 'deletions' => 0]]); + contribution(['actor_login' => 'muitas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 900, 'deletions' => 50]]); $filters = RetrospectiveFilters::make($this->since, $this->until, sort: 'lines'); $data = ($this->build)($filters); diff --git a/app-modules/portal/tests/Feature/HeroAvatarsTest.php b/app-modules/portal/tests/Feature/HeroAvatarsTest.php new file mode 100644 index 000000000..223e0bb9b --- /dev/null +++ b/app-modules/portal/tests/Feature/HeroAvatarsTest.php @@ -0,0 +1,66 @@ + 20 mensagens nos últimos + * 30 dias) e vincula uma identidade GitHub a ele. + */ +function activeUserWithGithub(string $externalAccountId): User +{ + $user = User::factory()->create(); + + $discordIdentity = ExternalIdentity::factory()->create([ + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + ]); + + Message::factory()->count(21)->create([ + 'external_identity_id' => $discordIdentity->id, + 'sent_at' => now()->subDay(), + ]); + + ExternalIdentity::factory()->create([ + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::GitHub, + 'external_account_id' => $externalAccountId, + ]); + + return $user; +} + +it('monta avatares a partir do CDN do GitHub usando o id numérico da conta', function (): void { + activeUserWithGithub('583231'); + + $avatars = livewire(HeroSection::class)->instance()->avatars(); + + expect($avatars)->toContain('https://avatars.githubusercontent.com/u/583231?v=4'); +}); + +it('nunca aponta os avatares para github.com (evita o Set-Cookie cross-site)', function (): void { + activeUserWithGithub('583231'); + + $avatars = livewire(HeroSection::class)->instance()->avatars(); + + expect($avatars)->not->toBeEmpty() + ->and(collect($avatars)->every(fn (string $url): bool => !str_contains($url, 'github.com/'))) + ->toBeTrue(); +}); + +it('ignora identidades GitHub cujo external_account_id não é numérico', function (): void { + activeUserWithGithub('not-a-numeric-id'); + + $avatars = livewire(HeroSection::class)->instance()->avatars(); + + expect($avatars)->toBeEmpty(); +}); diff --git a/app-modules/portal/tests/Feature/SocialLinksPageTest.php b/app-modules/portal/tests/Feature/SocialLinksPageTest.php index 93619010f..06c5a6ab5 100644 --- a/app-modules/portal/tests/Feature/SocialLinksPageTest.php +++ b/app-modules/portal/tests/Feature/SocialLinksPageTest.php @@ -18,7 +18,7 @@ expect(route('social-links', absolute: false))->toBe('/redes'); }); -it('renderiza os seis links com rótulos e URLs corretas', function (): void { +it('renderiza todos os links com rótulos e URLs corretas', function (): void { $assertion = get('/redes') ->assertOk(); @@ -32,8 +32,8 @@ it('abre cada link externo em nova aba com rel seguro', function (): void { $html = get('/redes')->getContent(); - expect(mb_substr_count($html, 'target="_blank"'))->toBe(6); - expect(mb_substr_count($html, 'rel="noopener noreferrer"'))->toBe(6); + expect(mb_substr_count($html, 'target="_blank"'))->toBe(9); + expect(mb_substr_count($html, 'rel="noopener noreferrer"'))->toBe(7); }); it('exibe o logo He4rt animado', function (): void { @@ -44,8 +44,8 @@ $html = get('/redes')->getContent(); expect($html)->toContain('Escolha seu canal'); - expect(mb_substr_count($html, '--accent-light:'))->toBe(6); - expect(mb_substr_count($html, '--accent-dark:'))->toBe(6); + expect(mb_substr_count($html, '--accent-light:'))->toBe(7); + expect(mb_substr_count($html, '--accent-dark:'))->toBe(7); expect($html)->toContain('--accent-light: #0F172A; --accent-dark: #FFFFFF;') ->and($html)->toContain('--accent-light: #111827; --accent-dark: #FFFFFF;'); }); diff --git a/app-modules/profile/database/factories/ProfileFactory.php b/app-modules/profile/database/factories/ProfileFactory.php index b6c062a8b..5fc6f1436 100644 --- a/app-modules/profile/database/factories/ProfileFactory.php +++ b/app-modules/profile/database/factories/ProfileFactory.php @@ -4,7 +4,6 @@ namespace He4rt\Profile\Database\Factories; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Profile\Enums\SeniorityLevel; use He4rt\Profile\Enums\SocialPlatform; @@ -24,7 +23,6 @@ public function definition(): array { return [ 'user_id' => User::factory(), - 'tenant_id' => Tenant::factory(), 'nickname' => null, 'birthdate' => null, 'about' => null, @@ -37,6 +35,28 @@ public function definition(): array ]; } + /** + * Reuse the profile that {@see \He4rt\Identity\User\Observers\UserObserver} + * already created for the user instead of inserting a second row, which + * would violate the (now tenant-free) unique constraint on `user_id`. + * + * @param array $attributes + */ + public function newModel(array $attributes = []): Profile + { + $userId = $attributes['user_id'] ?? null; + + if ($userId !== null) { + $existing = Profile::query()->where('user_id', $userId)->first(); + + if ($existing !== null) { + return $existing->fill($attributes); + } + } + + return parent::newModel($attributes); + } + public function complete(): self { return $this->state(fn (): array => [ diff --git a/app-modules/profile/database/migrations/2026_07_18_095900_add_qa_skills_to_catalog.php b/app-modules/profile/database/migrations/2026_07_18_095900_add_qa_skills_to_catalog.php new file mode 100644 index 000000000..291c32190 --- /dev/null +++ b/app-modules/profile/database/migrations/2026_07_18_095900_add_qa_skills_to_catalog.php @@ -0,0 +1,85 @@ +> + */ + private const array CATALOG = [ + 'framework' => [ + 'selenium' => 'Selenium', + 'cypress' => 'Cypress', + 'playwright' => 'Playwright', + 'webdriverio' => 'WebdriverIO', + 'testcafe' => 'TestCafe', + 'puppeteer' => 'Puppeteer', + 'appium' => 'Appium', + 'espresso' => 'Espresso', + 'xcuitest' => 'XCUITest', + 'detox' => 'Detox', + 'rest-assured' => 'RestAssured', + 'karate' => 'Karate', + 'supertest' => 'Supertest', + 'cucumber' => 'Cucumber', + 'specflow' => 'SpecFlow', + 'behave' => 'Behave', + 'robot-framework' => 'Robot Framework', + 'junit' => 'JUnit', + 'testng' => 'TestNG', + 'pytest' => 'PyTest', + 'jest' => 'Jest', + 'mocha-chai' => 'Mocha / Chai', + 'nunit' => 'NUnit', + 'xunit' => 'xUnit', + ], + 'tool' => [ + 'postman-newman' => 'Postman / Newman', + 'jmeter' => 'JMeter', + 'k6' => 'K6', + 'gatling' => 'Gatling', + 'locust' => 'Locust', + ], + ]; + + public function up(): void + { + $now = now(); + $rows = []; + + foreach (self::CATALOG as $category => $skills) { + foreach ($skills as $slug => $name) { + $rows[] = [ + 'id' => Str::uuid7()->toString(), + 'slug' => $slug, + 'name' => $name, + 'category' => $category, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + DB::table('skills')->upsert( + $rows, + ['slug'], + ['name', 'category', 'updated_at'], + ); + } + + public function down(): void + { + DB::table('skills') + ->whereIn('slug', array_keys(self::CATALOG['framework'])) + ->orWhereIn('slug', array_keys(self::CATALOG['tool'])) + ->delete(); + } +}; diff --git a/app-modules/profile/lang/en/enums.php b/app-modules/profile/lang/en/enums.php index 685489637..3ffd48b77 100644 --- a/app-modules/profile/lang/en/enums.php +++ b/app-modules/profile/lang/en/enums.php @@ -17,6 +17,7 @@ 'website' => 'Website', 'youtube' => 'YouTube', 'bluesky' => 'Bluesky', + 'linkedin' => 'LinkedIn', ], 'skill_category' => [ diff --git a/app-modules/profile/lang/pt_BR/enums.php b/app-modules/profile/lang/pt_BR/enums.php index e01c86c7d..1b1fa3b9b 100644 --- a/app-modules/profile/lang/pt_BR/enums.php +++ b/app-modules/profile/lang/pt_BR/enums.php @@ -17,6 +17,7 @@ 'website' => 'Website', 'youtube' => 'YouTube', 'bluesky' => 'Bluesky', + 'linkedin' => 'LinkedIn', ], 'skill_category' => [ diff --git a/app-modules/profile/src/Enums/SocialPlatform.php b/app-modules/profile/src/Enums/SocialPlatform.php index 8dccf0275..db6eaf65d 100644 --- a/app-modules/profile/src/Enums/SocialPlatform.php +++ b/app-modules/profile/src/Enums/SocialPlatform.php @@ -15,6 +15,7 @@ enum SocialPlatform: string implements HasIcon, HasLabel case Website = 'website'; case YouTube = 'youtube'; case Bluesky = 'bluesky'; + case LinkedIn = 'linkedin'; /** * @return array @@ -40,6 +41,25 @@ public function getIcon(): Heroicon self::Website => Heroicon::GlobeAlt, self::YouTube => Heroicon::PlayCircle, self::Bluesky => Heroicon::Cloud, + self::LinkedIn => Heroicon::Link + }; + } + + public function getUrl(string $handle): string + { + if (str_starts_with($handle, 'http://') || str_starts_with($handle, 'https://')) { + return $handle; + } + + $slug = mb_ltrim($handle, '@'); + + return match ($this) { + self::Instagram => 'https://instagram.com/'.$slug, + self::Twitter => 'https://x.com/'.$slug, + self::LinkedIn => 'https://linkedin.com/in/'.$slug, + self::YouTube => 'https://youtube.com/@'.$slug, + self::Bluesky => 'https://bsky.app/profile/'.$slug, + self::Website => 'https://'.$slug, }; } } diff --git a/app-modules/profile/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php index 433fc64e5..d2710e248 100644 --- a/app-modules/profile/src/Models/Profile.php +++ b/app-modules/profile/src/Models/Profile.php @@ -5,7 +5,6 @@ namespace He4rt\Profile\Models; use Carbon\CarbonInterface; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Profile\Casts\AsWorkPreferences; use He4rt\Profile\Data\WorkPreferences; @@ -27,7 +26,6 @@ /** * @property string $id * @property string $user_id - * @property string $tenant_id * @property string|null $nickname * @property CarbonInterface|null $birthdate * @property string|null $about @@ -53,7 +51,6 @@ final class Profile extends Model protected $fillable = [ 'user_id', - 'tenant_id', 'nickname', 'birthdate', 'about', @@ -68,12 +65,11 @@ final class Profile extends Model 'preferences', ]; - public static function ensureExists(string $userId, string $tenantId): self + public static function ensureExists(string $userId): self { /** @var Profile $profile */ $profile = self::query()->firstOrCreate([ 'user_id' => $userId, - 'tenant_id' => $tenantId, ], [ 'available_for_proposals' => false, ]); @@ -89,14 +85,6 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - /** * @return HasMany */ @@ -125,6 +113,11 @@ public function skills(): BelongsToMany ->withTimestamps(); } + protected static function newFactory(): ProfileFactory + { + return ProfileFactory::new(); + } + /** * @return Attribute|null> */ diff --git a/app-modules/profile/src/Models/Skill.php b/app-modules/profile/src/Models/Skill.php index d8062a29b..8240ec831 100644 --- a/app-modules/profile/src/Models/Skill.php +++ b/app-modules/profile/src/Models/Skill.php @@ -41,9 +41,10 @@ final class Skill extends Model * Server-side search for the skill select. Returns "Category · Name" labels, * capped — only matches travel to the client, so the catalog can grow freely. * + * @param array $exclude skill ids to omit (e.g. already chosen in a repeater) * @return array */ - public static function search(string $search): array + public static function search(string $search, array $exclude = []): array { $results = []; @@ -54,6 +55,7 @@ static function (Builder $inner) use ($search): void { ->orWhere('slug', 'ilike', '%'.$search.'%'); }, )) + ->when($exclude !== [], static fn (Builder $query): Builder => $query->whereNotIn('id', $exclude)) ->orderBy('name') ->limit(50) ->get(['id', 'name', 'category']) diff --git a/app-modules/profile/tests/Feature/ProfileCreationTest.php b/app-modules/profile/tests/Feature/ProfileCreationTest.php index 369adad0a..3ba724aba 100644 --- a/app-modules/profile/tests/Feature/ProfileCreationTest.php +++ b/app-modules/profile/tests/Feature/ProfileCreationTest.php @@ -2,23 +2,16 @@ declare(strict_types=1); -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Profile\Models\Profile; -test('profile is created when user joins tenant', function (): void { +test('profile is created when a user is created', function (): void { $user = User::factory()->create([ 'username' => 'danielhe4rt', ]); - $tenant = Tenant::factory()->create([ - 'name' => 'He4rt Developers', - ]); - - $tenant->members()->attach($user); $profile = Profile::query() ->whereBelongsTo($user) - ->whereBelongsTo($tenant) ->sole(); expect($profile->nickname)->toBeNull() @@ -30,56 +23,27 @@ $user = User::factory()->create([ 'username' => 'danielhe4rt', ]); - $tenant = Tenant::factory()->create([ - 'name' => 'He4rt Developers', - ]); - $existingProfile = Profile::factory()->create([ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'headline' => 'Existing headline', - ]); + $existingProfile = Profile::query()->whereBelongsTo($user)->sole(); + $existingProfile->update(['headline' => 'Existing headline']); - $tenant->members()->attach($user); + Profile::ensureExists((string) $user->id); expect(Profile::query() ->whereBelongsTo($user) - ->whereBelongsTo($tenant) ->count())->toBe(1) ->and($existingProfile->fresh()->headline)->toBe('Existing headline'); }); -test('member can have independent profiles in multiple tenants', function (): void { - $user = User::factory()->create([ - 'username' => 'danielhe4rt', - ]); - $firstTenant = Tenant::factory()->create([ - 'name' => 'He4rt Developers', - ]); - $secondTenant = Tenant::factory()->create([ - 'name' => 'Outro Tenant', - ]); - - $firstTenant->members()->attach($user); - $secondTenant->members()->attach($user); - - $profiles = Profile::query() - ->whereBelongsTo($user) - ->get(); - - $tenantIds = $profiles->pluck('tenant_id')->sort()->values()->all(); - $expectedTenantIds = collect([$firstTenant->id, $secondTenant->id])->sort()->values()->all(); - - expect($profiles)->toHaveCount(2) - ->and($tenantIds)->toBe($expectedTenantIds) - ->and($profiles->pluck('id')->unique())->toHaveCount(2); -}); - test('factory creates a valid profile and supports optional states', function (): void { - $profile = Profile::factory()->complete()->create(); + $user = User::factory()->create(); + // UserObserver already created a blank profile for this user; replace it + // so the factory's own insert does not collide with the unique user_id. + Profile::query()->where('user_id', $user->id)->delete(); + + $profile = Profile::factory()->complete()->create(['user_id' => $user->id]); expect($profile->user_id)->not->toBeNull() - ->and($profile->tenant_id)->not->toBeNull() ->and($profile->nickname)->not->toBeNull() ->and($profile->headline)->not->toBeNull() ->and($profile->available_for_proposals)->toBeTrue(); diff --git a/app-modules/profile/tests/Feature/SkillSearchTest.php b/app-modules/profile/tests/Feature/SkillSearchTest.php new file mode 100644 index 000000000..b8f191cce --- /dev/null +++ b/app-modules/profile/tests/Feature/SkillSearchTest.php @@ -0,0 +1,24 @@ +create(['name' => 'Testlang Keep', 'slug' => 'testlang-keep']); + $other = Skill::factory()->create(['name' => 'Testlang Other', 'slug' => 'testlang-other']); + + $results = Skill::search('testlang'); + + expect($results)->toHaveKeys([$keep->id, $other->id]); +}); + +test('search omits ids passed in the exclude list', function (): void { + $keep = Skill::factory()->create(['name' => 'Testlang Keep', 'slug' => 'testlang-keep']); + $drop = Skill::factory()->create(['name' => 'Testlang Drop', 'slug' => 'testlang-drop']); + + $results = Skill::search('testlang', exclude: [$drop->id]); + + expect($results)->toHaveKey($keep->id) + ->and($results)->not->toHaveKey($drop->id); +}); diff --git a/app-modules/profile/tests/Unit/SocialPlatformTest.php b/app-modules/profile/tests/Unit/SocialPlatformTest.php new file mode 100644 index 000000000..d7da83f3c --- /dev/null +++ b/app-modules/profile/tests/Unit/SocialPlatformTest.php @@ -0,0 +1,59 @@ + ['johndoe'], + 'username with @' => ['@johndoe'], +]); + +test('instagram builds correct url from handle', function (string $handle): void { + expect(SocialPlatform::Instagram->getUrl($handle)) + ->toBe('https://instagram.com/johndoe'); +})->with('username handles'); + +test('twitter builds correct url from handle', function (string $handle): void { + expect(SocialPlatform::Twitter->getUrl($handle)) + ->toBe('https://x.com/johndoe'); +})->with('username handles'); + +test('linkedin builds correct url from handle', function (string $handle): void { + expect(SocialPlatform::LinkedIn->getUrl($handle)) + ->toBe('https://linkedin.com/in/johndoe'); +})->with('username handles'); + +test('youtube builds correct url from handle', function (string $handle): void { + expect(SocialPlatform::YouTube->getUrl($handle)) + ->toBe('https://youtube.com/@johndoe'); +})->with('username handles'); + +test('bluesky builds correct url from handle', function (string $handle): void { + expect(SocialPlatform::Bluesky->getUrl($handle)) + ->toBe('https://bsky.app/profile/johndoe'); +})->with('username handles'); + +test('website prepends https when no scheme is provided', function (): void { + expect(SocialPlatform::Website->getUrl('example.dev')) + ->toBe('https://example.dev'); +}); + +test('full https url is returned as-is for any platform', function (): void { + $url = 'https://www.linkedin.com/in/johndoe/'; + + expect(SocialPlatform::LinkedIn->getUrl($url))->toBe($url); +}); + +test('full http url is returned as-is for any platform', function (): void { + $url = 'http://example.dev'; + + expect(SocialPlatform::Website->getUrl($url))->toBe($url); +}); + +test('full url is not double-prefixed for all platforms', function (): void { + foreach (SocialPlatform::cases() as $platform) { + $url = 'https://example.com/foo'; + expect($platform->getUrl($url))->toBe($url); + } +}); diff --git a/app/Console/Commands/GenerateDiscordTenant.php b/app/Console/Commands/GenerateDiscordTenant.php deleted file mode 100644 index 3e1e821a0..000000000 --- a/app/Console/Commands/GenerateDiscordTenant.php +++ /dev/null @@ -1,43 +0,0 @@ -afterCreating(function (Tenant $tenant): void { - $tenant - ->providers() - ->create([ - 'tenant_id' => $tenant->getKey(), - 'type' => IdentityType::External, - 'provider' => IdentityProvider::Discord, - 'credentials_type' => CredentialsType::OAuth2, - 'credentials' => ClientAccessManager::make(), - 'external_account_id' => $this->argument('guildId'), - 'connected_at' => now(), - ]); - }) - ->create(); - - $this->info('Tenant created successfully!'); - } -} diff --git a/app/Console/Commands/ImportDiscordMembers.php b/app/Console/Commands/ImportDiscordMembers.php index 171eeb614..f5bb37aa1 100644 --- a/app/Console/Commands/ImportDiscordMembers.php +++ b/app/Console/Commands/ImportDiscordMembers.php @@ -7,11 +7,9 @@ use He4rt\Identity\ExternalIdentity\Actions\CreateAccountByExternalIdentity; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Support\Facades\Storage; #[Description(description: 'Import Discord members and GitHub connections from JSON files into the database')] @@ -36,7 +34,6 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void $membersData = json_decode((string) Storage::disk('local')->get($membersFile), associative: true); $members = $membersData['members'] ?? []; - $guildId = $membersData['guild_id'] ?? config('he4rt.discord.guild_id'); $this->info('Loaded '.count($members).sprintf(' members from %s (scraped: %s)', $membersFile, $membersData['scraped_at'])); @@ -54,23 +51,7 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void $this->warn(sprintf('GitHub file not found (%s), skipping GitHub imports.', $githubFile)); } - // Find tenant by guild ID - $tenant = Tenant::query() - ->whereHas('providers', fn (Builder $q) => $q - ->where('provider', IdentityProvider::Discord->value) - ->where('provider_id', $guildId) - ) - ->first(); - - if (!$tenant) { - $this->error(sprintf('No tenant found for guild ID %s. Run `php artisan misc:generate-discord-tenant %s` first.', $guildId, $guildId)); - - return; - } - - $this->info(sprintf('Importing into tenant: %s (ID: %d)', $tenant->name, $tenant->id)); - - $stats = ['created' => 0, 'existing' => 0, 'github_set' => 0, 'github_skipped' => 0, 'tenant_attached' => 0]; + $stats = ['created' => 0, 'existing' => 0, 'github_set' => 0, 'github_skipped' => 0]; $csvRows = []; $isDryRun = $this->option('dry-run'); $this->option('force'); @@ -106,7 +87,6 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void if (!$isDryRun) { $identity = $createAccount->handle( - tenantId: $tenant->id, provider: IdentityProvider::Discord, providerId: $discordId, username: $username, @@ -115,12 +95,6 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void } } - // Attach user to tenant if not already - if ($user && !$isDryRun && !$tenant->members()->where('user_id', $user->id)->exists()) { - $tenant->members()->attach($user->id); - $stats['tenant_attached']++; - } - // GitHub URL import removed — legacy information() model no longer exists. // GitHub is now derived from ExternalIdentity (OAuth) connections. @@ -160,7 +134,6 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void [$prefix.'Existing users found', $stats['existing']], [$prefix.'GitHub URLs set', $stats['github_set']], [$prefix.'GitHub URLs skipped (existing)', $stats['github_skipped']], - [$prefix.'Users attached to tenant', $stats['tenant_attached']], ] ); } diff --git a/app/Http/Middleware/GuestTenantIdentifier.php b/app/Http/Middleware/GuestTenantIdentifier.php deleted file mode 100644 index 74e3215da..000000000 --- a/app/Http/Middleware/GuestTenantIdentifier.php +++ /dev/null @@ -1,42 +0,0 @@ -hasTenancy()) { - return $next($request); - } - - if (!$request->route()->hasParameter('tenant')) { - return $next($request); - } - - $tenantSlug = $request->route()->parameter('tenant'); - - if (!is_string($tenantSlug)) { - return $next($request); - } - - $tenant = $panel->getTenant($tenantSlug); - Filament::setTenant($tenant, isQuiet: true); - - return $next($request); - } -} diff --git a/app/Livewire/ConnectionHub.php b/app/Livewire/ConnectionHub.php index b0f71acbe..081562764 100644 --- a/app/Livewire/ConnectionHub.php +++ b/app/Livewire/ConnectionHub.php @@ -8,7 +8,6 @@ use He4rt\Identity\Auth\Actions\MergeAccountsAction; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; @@ -19,8 +18,6 @@ class ConnectionHub extends Component { public string $panel = 'app'; - public string $tenantId = ''; - public bool $showMergeModal = false; /** @var array|null */ @@ -29,7 +26,6 @@ class ConnectionHub extends Component public function mount(): void { $this->panel = filament()->getCurrentPanel()?->getId() ?? 'app'; - $this->tenantId = filament()->getTenant()?->getKey() ?? ''; $this->checkPendingMerge(); } @@ -55,10 +51,7 @@ public function render(): View public function connect(IdentityProvider $provider): void { - $tenant = Tenant::query()->find($this->tenantId); - $this->redirect(route('oauth.redirect', [ - 'tenant' => $tenant->domain ?? $tenant->slug, 'panel' => $this->panel, 'provider' => $provider->value, ])); @@ -94,7 +87,7 @@ public function confirmMerge(MergeAccountsAction $action): void ->success() ->send(); - $this->redirect(filament()->getCurrentPanel()->getUrl(filament()->getTenant())); + $this->redirect(filament()->getCurrentPanel()->getUrl()); } public function cancelMerge(): void @@ -108,7 +101,6 @@ public function disconnect(IdentityProvider $provider): void { $identity = auth()->user() ->providers() - ->where('tenant_id', $this->tenantId) ->where('provider', $provider->value) ->whereNotNull('connected_at') ->whereNull('disconnected_at') @@ -135,7 +127,6 @@ public function disconnectById(string $identityId): void { $identity = ExternalIdentity::query() ->where('id', $identityId) - ->where('tenant_id', $this->tenantId) ->whereNotNull('connected_at') ->whereNull('disconnected_at') ->first(); @@ -201,14 +192,13 @@ private function getMergeTarget(): ?array /** @return Collection */ private function getUserProviders(): Collection { - return auth()->user()->providers()->where('tenant_id', $this->tenantId)->get(); + return auth()->user()->providers()->get(); } /** @return Collection */ private function getTenantProviders(): Collection { return ExternalIdentity::query() - ->where('tenant_id', $this->tenantId) ->where('model_type', 'tenant') ->whereNotNull('connected_at') ->whereNull('disconnected_at') diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 4685346fb..123d9fcd5 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -14,10 +14,7 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; -use He4rt\Identity\Tenant\Models\Tenant; -use He4rt\PanelAdmin\Http\Middleware\ApplyTenantScopes; use He4rt\PanelAdmin\Pages\Dashboard; -use He4rt\PanelAdmin\Tenant\EditTenantProfilePage; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; @@ -47,8 +44,6 @@ public function panel(Panel $panel): Panel ]; }) ->sidebarCollapsibleOnDesktop() - ->tenant(Tenant::class, slugAttribute: 'slug') - ->tenantProfile(EditTenantProfilePage::class) ->viteTheme('resources/css/filament/admin/theme.css') ->middleware([ EncryptCookies::class, @@ -67,9 +62,6 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, - ]) - ->tenantMiddleware([ - ApplyTenantScopes::class, ]); } } diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 52cb04081..bacc91b3b 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -13,7 +13,6 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; -use He4rt\Identity\Tenant\Models\Tenant; use He4rt\PanelApp\Pages\EventPage; use He4rt\PanelApp\Pages\EventsPage; use He4rt\PanelApp\Pages\LoginPage; @@ -34,7 +33,7 @@ class AppPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { - $panel + return $panel ->id($this->panelId->value) ->path($this->panelId->value) ->login(LoginPage::class) @@ -71,11 +70,5 @@ public function panel(Panel $panel): Panel ->authMiddleware([ Authenticate::class, ]); - - app()->isProduction() - ? $panel->tenantDomain('{tenant:domain}')->tenant(model: Tenant::class, slugAttribute: 'domain') - : $panel->tenant(model: Tenant::class, slugAttribute: 'slug'); - - return $panel; } } diff --git a/composer.json b/composer.json index e84e22e26..6a75e873e 100644 --- a/composer.json +++ b/composer.json @@ -11,10 +11,10 @@ "php": "^8.4", "bacon/bacon-qr-code": "^2.0.8", "calebporzio/sushi": "^2.5.4", - "dedoc/scramble": "^0.13.32", - "filament/filament": "^5.6.8", - "filament/spatie-laravel-media-library-plugin": "^5.6.8", - "guzzlehttp/guzzle": "^7.14.0", + "dedoc/scramble": "^0.13.35", + "filament/filament": "^5.7.3", + "filament/spatie-laravel-media-library-plugin": "^5.7.3", + "guzzlehttp/guzzle": "^7.15.1", "he4rt/activity": "^1.0.0", "he4rt/bot-discord": "^1.0.0", "he4rt/community": "^1.0.0", @@ -38,21 +38,21 @@ "he4rt/squads": "^1.0.0", "internachi/modular": "^3.0.2", "laracord/framework": "dev-next#e7b64d6", - "laravel/framework": "^13.19.0", + "laravel/framework": "^13.21.1", "laravel/nightwatch": "^1.28.4", - "laravel/sanctum": "^4.3.2", - "laravel/telescope": "^5.20.0", + "laravel/sanctum": "^4.3.3", + "laravel/telescope": "^5.21.0", "laravel/tinker": "^3.0.2", "league/flysystem-sftp-v3": "^3.33.0", "livewire/flux": "^2.15.0", "marvinlabs/laravel-discord-logger": "^1.4.4", "monicahq/laravel-cloudflare": "^4.1", - "owenvoke/blade-fontawesome": "^3.3.0", - "phiki/phiki": "^2.2", + "owenvoke/blade-fontawesome": "^3.3.1", + "phiki/phiki": "^2.2.1", "ryangjchandler/commonmark-blade-block": "^1.1.1", "saloonphp/saloon": "^4.0", "spatie/laravel-backup": "^10.3.0", - "spatie/laravel-medialibrary": "^11.23.1", + "spatie/laravel-medialibrary": "^11.23.3", "symfony/dom-crawler": "^8.1.1", "symfony/yaml": "^8.1.1" }, @@ -61,20 +61,20 @@ "fakerphp/faker": "^1.24.1", "fruitcake/laravel-debugbar": "^4.4.0", "larastan/larastan": "^3.10.0", - "laravel/boost": "^2.4.12", + "laravel/boost": "^2.4.13", "laravel/pail": "^1.2.7", "laravel/pao": "^1.1.2", "laravel/pint": "^1.29.3", "mockery/mockery": "^1.6.12", "mrpunyapal/rector-pest": "^0.2.17", - "nunomaduro/collision": "^8.9.4", + "nunomaduro/collision": "^8.9.5", "pestphp/pest": "^4.7.5", "pestphp/pest-plugin-drift": "^4.1.0", "pestphp/pest-plugin-faker": "^4.0.0", "pestphp/pest-plugin-laravel": "^4.1.0", "pestphp/pest-plugin-livewire": "^4.1.0", "phpstan/extension-installer": "^1.4.3", - "rector/rector": "^2.5.5" + "rector/rector": "^2.5.7" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 938bb3d83..18b392adb 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": "8486c7b6b0ca0dda54288a27381865a1", + "content-hash": "133908fcd54b75f97163b7108681fd67", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -621,16 +621,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.12", + "version": "1.5.13", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + "reference": "c008272789979f709f7fcb32c2ecf1d2db5e84e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/c008272789979f709f7fcb32c2ecf1d2db5e84e5", + "reference": "c008272789979f709f7fcb32c2ecf1d2db5e84e5", "shasum": "" }, "require": { @@ -677,7 +677,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + "source": "https://github.com/composer/ca-bundle/tree/1.5.13" }, "funding": [ { @@ -689,7 +689,7 @@ "type": "github" } ], - "time": "2026-05-19T11:26:22+00:00" + "time": "2026-07-18T12:35:13+00:00" }, { "name": "composer/class-map-generator", @@ -1390,16 +1390,16 @@ }, { "name": "dedoc/scramble", - "version": "v0.13.32", + "version": "v0.13.35", "source": { "type": "git", "url": "https://github.com/dedoc/scramble.git", - "reference": "546a727720d4c38f2248e7c63b43178fbea7a96e" + "reference": "c072f731e060972134b6916ca6899609bacc0dd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dedoc/scramble/zipball/546a727720d4c38f2248e7c63b43178fbea7a96e", - "reference": "546a727720d4c38f2248e7c63b43178fbea7a96e", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/c072f731e060972134b6916ca6899609bacc0dd2", + "reference": "c072f731e060972134b6916ca6899609bacc0dd2", "shasum": "" }, "require": { @@ -1459,7 +1459,7 @@ ], "support": { "issues": "https://github.com/dedoc/scramble/issues", - "source": "https://github.com/dedoc/scramble/tree/v0.13.32" + "source": "https://github.com/dedoc/scramble/tree/v0.13.35" }, "funding": [ { @@ -1467,7 +1467,7 @@ "type": "github" } ], - "time": "2026-07-09T09:04:02+00:00" + "time": "2026-07-15T13:21:15+00:00" }, { "name": "dflydev/dot-access-data", @@ -2171,16 +2171,16 @@ }, { "name": "filament/actions", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "1c479a47ee1612f7c05a7921b2491230aeed392c" + "reference": "dfb15d45b91f288b9f8245dbf590ae8332a5a03d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/1c479a47ee1612f7c05a7921b2491230aeed392c", - "reference": "1c479a47ee1612f7c05a7921b2491230aeed392c", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/dfb15d45b91f288b9f8245dbf590ae8332a5a03d", + "reference": "dfb15d45b91f288b9f8245dbf590ae8332a5a03d", "shasum": "" }, "require": { @@ -2216,20 +2216,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:19:13+00:00" + "time": "2026-07-22T16:56:03+00:00" }, { "name": "filament/filament", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "c54ec7692245787057c24f82d92d9fee7bc24941" + "reference": "711978b0d14a7cf0bc222d304be07efd7baa2ba1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/c54ec7692245787057c24f82d92d9fee7bc24941", - "reference": "c54ec7692245787057c24f82d92d9fee7bc24941", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/711978b0d14a7cf0bc222d304be07efd7baa2ba1", + "reference": "711978b0d14a7cf0bc222d304be07efd7baa2ba1", "shasum": "" }, "require": { @@ -2273,20 +2273,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:17:14+00:00" + "time": "2026-07-22T16:55:00+00:00" }, { "name": "filament/forms", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb" + "reference": "5432b78a7f1d30407c29ea6fcf98ffc9daa4b863" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb", - "reference": "7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/5432b78a7f1d30407c29ea6fcf98ffc9daa4b863", + "reference": "5432b78a7f1d30407c29ea6fcf98ffc9daa4b863", "shasum": "" }, "require": { @@ -2323,20 +2323,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:16:27+00:00" + "time": "2026-07-22T16:55:09+00:00" }, { "name": "filament/infolists", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7" + "reference": "47af660679dcc036eeb995243285304156adc7d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7", - "reference": "01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/47af660679dcc036eeb995243285304156adc7d4", + "reference": "47af660679dcc036eeb995243285304156adc7d4", "shasum": "" }, "require": { @@ -2368,20 +2368,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:19:40+00:00" + "time": "2026-07-22T16:50:42+00:00" }, { "name": "filament/notifications", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", - "reference": "a2b4564d9c7fcb5b8e904da40b4f6c2782934071" + "reference": "a9dfab2f4fdedc5ddcce8cec5292ddc779d0438e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/notifications/zipball/a2b4564d9c7fcb5b8e904da40b4f6c2782934071", - "reference": "a2b4564d9c7fcb5b8e904da40b4f6c2782934071", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/a9dfab2f4fdedc5ddcce8cec5292ddc779d0438e", + "reference": "a9dfab2f4fdedc5ddcce8cec5292ddc779d0438e", "shasum": "" }, "require": { @@ -2415,20 +2415,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:08:59+00:00" + "time": "2026-07-22T16:56:07+00:00" }, { "name": "filament/query-builder", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/query-builder.git", - "reference": "401d0d61f5ead5d440f184857e1921675138fd5a" + "reference": "66a963dc5a06988bc9fd0c16ac5a66f3f63a3b61" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/401d0d61f5ead5d440f184857e1921675138fd5a", - "reference": "401d0d61f5ead5d440f184857e1921675138fd5a", + "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/66a963dc5a06988bc9fd0c16ac5a66f3f63a3b61", + "reference": "66a963dc5a06988bc9fd0c16ac5a66f3f63a3b61", "shasum": "" }, "require": { @@ -2461,20 +2461,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:20:10+00:00" + "time": "2026-07-22T16:56:05+00:00" }, { "name": "filament/schemas", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", - "reference": "036549f1b6cbf3d908e1b9fa42f5e221d1db3240" + "reference": "b9b7988da3a21a5ea32066011c5e4be7b9a16ce6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/schemas/zipball/036549f1b6cbf3d908e1b9fa42f5e221d1db3240", - "reference": "036549f1b6cbf3d908e1b9fa42f5e221d1db3240", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/b9b7988da3a21a5ea32066011c5e4be7b9a16ce6", + "reference": "b9b7988da3a21a5ea32066011c5e4be7b9a16ce6", "shasum": "" }, "require": { @@ -2506,20 +2506,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:20:18+00:00" + "time": "2026-07-22T16:54:36+00:00" }, { "name": "filament/spatie-laravel-media-library-plugin", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", - "reference": "085dda340dd99dd350d9f8fd77015cd420d8b2d2" + "reference": "16d3c2b0a4a47fd03dfe5f448f6b3bb5a53b0587" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/085dda340dd99dd350d9f8fd77015cd420d8b2d2", - "reference": "085dda340dd99dd350d9f8fd77015cd420d8b2d2", + "url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/16d3c2b0a4a47fd03dfe5f448f6b3bb5a53b0587", + "reference": "16d3c2b0a4a47fd03dfe5f448f6b3bb5a53b0587", "shasum": "" }, "require": { @@ -2543,20 +2543,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-22T07:42:50+00:00" + "time": "2026-07-17T11:47:27+00:00" }, { "name": "filament/support", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "024caa553010c2617bb90114eb0e78883e33138b" + "reference": "0e41d23d71972f7c37daee6255606776e1f6313d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/024caa553010c2617bb90114eb0e78883e33138b", - "reference": "024caa553010c2617bb90114eb0e78883e33138b", + "url": "https://api.github.com/repos/filamentphp/support/zipball/0e41d23d71972f7c37daee6255606776e1f6313d", + "reference": "0e41d23d71972f7c37daee6255606776e1f6313d", "shasum": "" }, "require": { @@ -2601,20 +2601,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:20:15+00:00" + "time": "2026-07-22T20:02:06+00:00" }, { "name": "filament/tables", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a" + "reference": "988cf1d4531c2f2d54a6ebaaf51d736a73ea1f9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a", - "reference": "bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/988cf1d4531c2f2d54a6ebaaf51d736a73ea1f9d", + "reference": "988cf1d4531c2f2d54a6ebaaf51d736a73ea1f9d", "shasum": "" }, "require": { @@ -2647,20 +2647,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:20:10+00:00" + "time": "2026-07-22T16:54:42+00:00" }, { "name": "filament/widgets", - "version": "v5.6.8", + "version": "v5.7.3", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "329c92e8560536fd6dd738b45eaf6ba063cd53ea" + "reference": "585d77e2b6b60693bdae525a2e791bd4d37b748c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/329c92e8560536fd6dd738b45eaf6ba063cd53ea", - "reference": "329c92e8560536fd6dd738b45eaf6ba063cd53ea", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/585d77e2b6b60693bdae525a2e791bd4d37b748c", + "reference": "585d77e2b6b60693bdae525a2e791bd4d37b748c", "shasum": "" }, "require": { @@ -2691,7 +2691,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-07-02T08:19:55+00:00" + "time": "2026-07-22T16:55:35+00:00" }, { "name": "fruitcake/php-cors", @@ -2828,22 +2828,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.14.0", + "version": "7.15.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "aef242412e13128b5049864867bb49fc37dd39de" + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", - "reference": "aef242412e13128b5049864867bb49fc37dd39de", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^2.5.1", - "guzzlehttp/psr7": "^2.12.4", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -2856,7 +2856,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.3", - "guzzlehttp/test-server": "^0.6", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -2936,7 +2936,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.14.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.1" }, "funding": [ { @@ -2952,7 +2952,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T22:54:09+00:00" + "time": "2026-07-18T11:23:11+00:00" }, { "name": "guzzlehttp/promises", @@ -3040,16 +3040,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.12.4", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", - "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -3139,7 +3139,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.4" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -3155,20 +3155,20 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:56:20+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.9", + "version": "v1.0.10", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176" + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d7580af6d3f8384325d9cd3e99b21c3ed1848176", - "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", "shasum": "" }, "require": { @@ -3225,7 +3225,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.9" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" }, "funding": [ { @@ -3241,7 +3241,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T16:19:22+00:00" + "time": "2026-07-17T13:53:03+00:00" }, { "name": "he4rt/activity", @@ -4260,16 +4260,16 @@ }, { "name": "kirschbaum-development/eloquent-power-joins", - "version": "4.3.2", + "version": "4.3.3", "source": { "type": "git", "url": "https://github.com/kirschbaum-development/eloquent-power-joins.git", - "reference": "33c189bd51a510c1ceba67222395ead08a29863a" + "reference": "c609dbbe4ad2051b667e937f1ab554067519d64b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/33c189bd51a510c1ceba67222395ead08a29863a", - "reference": "33c189bd51a510c1ceba67222395ead08a29863a", + "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/c609dbbe4ad2051b667e937f1ab554067519d64b", + "reference": "c609dbbe4ad2051b667e937f1ab554067519d64b", "shasum": "" }, "require": { @@ -4317,9 +4317,9 @@ ], "support": { "issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues", - "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.2" + "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.3" }, - "time": "2026-05-28T20:35:55+00:00" + "time": "2026-07-23T11:41:37+00:00" }, { "name": "laracord/framework", @@ -4406,16 +4406,16 @@ }, { "name": "laravel/framework", - "version": "v13.19.0", + "version": "v13.22.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "514502b38e11bd676ecf83b271c9452cc7500f16" + "reference": "d354afa45334cdac523fbc015f203a78d7e2f5c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/514502b38e11bd676ecf83b271c9452cc7500f16", - "reference": "514502b38e11bd676ecf83b271c9452cc7500f16", + "url": "https://api.github.com/repos/laravel/framework/zipball/d354afa45334cdac523fbc015f203a78d7e2f5c6", + "reference": "d354afa45334cdac523fbc015f203a78d7e2f5c6", "shasum": "" }, "require": { @@ -4494,6 +4494,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/image": "self.version", "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", @@ -4520,6 +4521,7 @@ "ext-gmp": "*", "fakerphp/faker": "^1.24", "guzzlehttp/psr7": "^2.9", + "intervention/image": "^4.0", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -4556,6 +4558,7 @@ "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", @@ -4626,7 +4629,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-07-07T14:13:33+00:00" + "time": "2026-07-24T20:38:48+00:00" }, { "name": "laravel/nightwatch", @@ -4783,16 +4786,16 @@ }, { "name": "laravel/sanctum", - "version": "v4.3.2", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", - "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", "shasum": "" }, "require": { @@ -4842,7 +4845,7 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-04-30T11:46:25+00:00" + "time": "2026-06-23T18:26:55+00:00" }, { "name": "laravel/sentinel", @@ -4902,16 +4905,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.13", + "version": "v2.0.15", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", "shasum": "" }, "require": { @@ -4959,20 +4962,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-16T14:03:50+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { "name": "laravel/telescope", - "version": "v5.20.0", + "version": "v5.21.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8" + "reference": "f00f45a59843329b97aa238f28da594f3b7d4aa7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8", - "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8", + "url": "https://api.github.com/repos/laravel/telescope/zipball/f00f45a59843329b97aa238f28da594f3b7d4aa7", + "reference": "f00f45a59843329b97aa238f28da594f3b7d4aa7", "shasum": "" }, "require": { @@ -5026,9 +5029,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.20.0" + "source": "https://github.com/laravel/telescope/tree/v5.21.0" }, - "time": "2026-04-06T12:52:26+00:00" + "time": "2026-07-20T19:44:45+00:00" }, { "name": "laravel/tinker", @@ -5101,16 +5104,16 @@ }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.8.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "shasum": "" }, "require": { @@ -5132,8 +5135,8 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", @@ -5204,7 +5207,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -5562,16 +5565,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -5581,7 +5584,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -5602,7 +5605,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -5614,7 +5617,7 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/uri", @@ -6497,16 +6500,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.0", + "version": "3.13.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", "shasum": "" }, "require": { @@ -6598,7 +6601,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T13:49:15+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/php-generator", @@ -6743,16 +6746,16 @@ }, { "name": "nette/utils", - "version": "v4.1.4", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { @@ -6772,7 +6775,7 @@ }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", @@ -6828,9 +6831,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.4" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2026-05-11T20:49:54+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nikic/php-parser", @@ -7071,16 +7074,16 @@ }, { "name": "owenvoke/blade-fontawesome", - "version": "v3.3.0", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/owenvoke/blade-fontawesome.git", - "reference": "b155b249742a49931c60a05cf43d33bc3f7c098e" + "reference": "e1f679a1da22e75bdad9fe19865c3b0bff83c5e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/owenvoke/blade-fontawesome/zipball/b155b249742a49931c60a05cf43d33bc3f7c098e", - "reference": "b155b249742a49931c60a05cf43d33bc3f7c098e", + "url": "https://api.github.com/repos/owenvoke/blade-fontawesome/zipball/e1f679a1da22e75bdad9fe19865c3b0bff83c5e3", + "reference": "e1f679a1da22e75bdad9fe19865c3b0bff83c5e3", "shasum": "" }, "require": { @@ -7115,7 +7118,7 @@ "description": "A package to easily make use of Font Awesome in your Laravel Blade views", "support": { "issues": "https://github.com/owenvoke/blade-fontawesome/issues", - "source": "https://github.com/owenvoke/blade-fontawesome/tree/v3.3.0" + "source": "https://github.com/owenvoke/blade-fontawesome/tree/v3.3.1" }, "funding": [ { @@ -7127,7 +7130,7 @@ "type": "github" } ], - "time": "2026-06-26T07:46:10+00:00" + "time": "2026-07-16T08:22:20+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -7250,16 +7253,16 @@ }, { "name": "phiki/phiki", - "version": "v2.2.0", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/phikiphp/phiki.git", - "reference": "24375dbc474dbc484de7d53c6bab0bb9e198f647" + "reference": "0a9ff7efad9d433d9b173aff36b2a1905ef13c57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phikiphp/phiki/zipball/24375dbc474dbc484de7d53c6bab0bb9e198f647", - "reference": "24375dbc474dbc484de7d53c6bab0bb9e198f647", + "url": "https://api.github.com/repos/phikiphp/phiki/zipball/0a9ff7efad9d433d9b173aff36b2a1905ef13c57", + "reference": "0a9ff7efad9d433d9b173aff36b2a1905ef13c57", "shasum": "" }, "require": { @@ -7308,7 +7311,7 @@ "description": "Syntax highlighting using TextMate grammars in PHP.", "support": { "issues": "https://github.com/phikiphp/phiki/issues", - "source": "https://github.com/phikiphp/phiki/tree/v2.2.0" + "source": "https://github.com/phikiphp/phiki/tree/v2.2.1" }, "funding": [ { @@ -7320,7 +7323,7 @@ "type": "other" } ], - "time": "2026-04-01T15:56:08+00:00" + "time": "2026-07-22T19:51:40+00:00" }, { "name": "phpoption/phpoption", @@ -10312,16 +10315,16 @@ }, { "name": "spatie/laravel-medialibrary", - "version": "11.23.1", + "version": "11.23.3", "source": { "type": "git", "url": "https://github.com/spatie/laravel-medialibrary.git", - "reference": "143106b61b945f1ca815e6a71598c03881469228" + "reference": "b578814b8c81a68c8ec9c9005246df5d417fe0f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/143106b61b945f1ca815e6a71598c03881469228", - "reference": "143106b61b945f1ca815e6a71598c03881469228", + "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/b578814b8c81a68c8ec9c9005246df5d417fe0f9", + "reference": "b578814b8c81a68c8ec9c9005246df5d417fe0f9", "shasum": "" }, "require": { @@ -10406,7 +10409,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-medialibrary/issues", - "source": "https://github.com/spatie/laravel-medialibrary/tree/11.23.1" + "source": "https://github.com/spatie/laravel-medialibrary/tree/11.23.3" }, "funding": [ { @@ -10418,7 +10421,7 @@ "type": "github" } ], - "time": "2026-06-24T08:31:39+00:00" + "time": "2026-07-22T07:06:25+00:00" }, { "name": "spatie/laravel-package-tools", @@ -13771,16 +13774,16 @@ }, { "name": "team-reflex/discord-php", - "version": "v10.51.0", + "version": "v10.52.3", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP.git", - "reference": "02f7c48e5e1af09564f77b70efed429acc52d96f" + "reference": "c932bc23eb916da03ebb6bc6315a47d0380e72db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/02f7c48e5e1af09564f77b70efed429acc52d96f", - "reference": "02f7c48e5e1af09564f77b70efed429acc52d96f", + "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/c932bc23eb916da03ebb6bc6315a47d0380e72db", + "reference": "c932bc23eb916da03ebb6bc6315a47d0380e72db", "shasum": "" }, "require": { @@ -13852,7 +13855,7 @@ "chat": "https://discord.gg/dphp", "docs": "https://discord-php.github.io/DiscordPHP/", "issues": "https://github.com/discord-php/DiscordPHP/issues", - "source": "https://github.com/discord-php/DiscordPHP/tree/v10.51.0", + "source": "https://github.com/discord-php/DiscordPHP/tree/v10.52.3", "wiki": "https://github.com/discord-php/DiscordPHP/wiki" }, "funding": [ @@ -13873,7 +13876,7 @@ "type": "patreon" } ], - "time": "2026-07-01T05:08:27+00:00" + "time": "2026-07-21T11:51:48+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -14988,16 +14991,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.12", + "version": "v2.4.13", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "d43bdf901fee8d216145cb062c9847c892844908" + "reference": "f55e08f5afa89ac72f23f574175005b67878f466" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/d43bdf901fee8d216145cb062c9847c892844908", - "reference": "d43bdf901fee8d216145cb062c9847c892844908", + "url": "https://api.github.com/repos/laravel/boost/zipball/f55e08f5afa89ac72f23f574175005b67878f466", + "reference": "f55e08f5afa89ac72f23f574175005b67878f466", "shasum": "" }, "require": { @@ -15006,7 +15009,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.7.1|^0.8.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -15050,20 +15053,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-07-08T09:53:34+00:00" + "time": "2026-07-17T14:28:57+00:00" }, { "name": "laravel/mcp", - "version": "v0.8.2", + "version": "v0.9.1", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "0c32bf369c6432cab21458f9f4479da33a49ba37" + "reference": "a08884d79a95c5143498507aec5badf751cdbec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/0c32bf369c6432cab21458f9f4479da33a49ba37", - "reference": "0c32bf369c6432cab21458f9f4479da33a49ba37", + "url": "https://api.github.com/repos/laravel/mcp/zipball/a08884d79a95c5143498507aec5badf751cdbec4", + "reference": "a08884d79a95c5143498507aec5badf751cdbec4", "shasum": "" }, "require": { @@ -15124,7 +15127,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-06-25T14:00:45+00:00" + "time": "2026-07-21T13:23:52+00:00" }, { "name": "laravel/pail", @@ -15573,23 +15576,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.4", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.8" + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -15597,12 +15600,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.6", - "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", - "laravel/pint": "^1.29.1", - "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", - "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -15665,7 +15668,7 @@ "type": "patreon" } ], - "time": "2026-04-21T14:04:20+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "pestphp/pest", @@ -17343,16 +17346,16 @@ }, { "name": "rector/rector", - "version": "2.5.5", + "version": "2.5.7", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e" + "reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e", - "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/ba22f8c087848278fed6b4910d4cf1108096d8d3", + "reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3", "shasum": "" }, "require": { @@ -17391,7 +17394,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.5.5" + "source": "https://github.com/rectorphp/rector/tree/2.5.7" }, "funding": [ { @@ -17399,7 +17402,7 @@ "type": "github" } ], - "time": "2026-07-09T09:48:44+00:00" + "time": "2026-07-13T15:24:18+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/he4rt.php b/config/he4rt.php index 4e257db35..07b846348 100644 --- a/config/he4rt.php +++ b/config/he4rt.php @@ -6,12 +6,16 @@ return [ 'admins' => env('HE4RT_ADMINS_USERNAMES', 'danielhe4rt,kaster'), - 'main_tenant' => env('HE4RT_MAIN_TENANT', 'he4rt'), 'season' => [ 'id' => (int) env('HE4RT_SEASON_ID', 2), 'minimum_level_for_retro' => env('HE4RT_SEASON_MIN_LEVEL', 3), ], 'server_key' => env('HE4RT_BOT_SECRET', 'he4rt'), + + 'features' => [ + 'timeline_pin' => env('HE4RT_FEATURE_TIMELINE_PIN', default: false), + ], + 'discord' => [ 'token' => env('DISCORD_TOKEN'), 'voice_xp_interval' => (int) env('HE4RT_DISCORD_VOICE_XP_INTERVAL', 1_200), @@ -49,4 +53,19 @@ 'badges_count' => 0, ], ], + + /* + * Fonte única das redes sociais da He4rt. Alimenta a página /redes + * (He4rt\Portal\Livewire\SocialLinksPage) e qualquer outro lugar que + * precise dos links. `accent_dark` é opcional (cai no `accent` quando ausente). + */ + 'social_media' => [ + 'discord' => ['label' => 'Discord', 'url' => 'https://discord.gg/invite/he4rt', 'icon' => 'fab-discord', 'accent' => '#5865F2'], + 'twitter' => ['label' => 'X (Twitter)', 'url' => 'https://x.com/He4rtDevs', 'icon' => 'fab-x-twitter', 'accent' => '#0F172A', 'accent_dark' => '#FFFFFF'], + 'linkedin' => ['label' => 'LinkedIn', 'url' => 'https://www.linkedin.com/company/he4rt/', 'icon' => 'fab-linkedin', 'accent' => '#0A66C2'], + 'whatsapp' => ['label' => 'WhatsApp', 'url' => 'https://chat.whatsapp.com/EBKjYxIodpe1x5LLExbTzK', 'icon' => 'fab-whatsapp', 'accent' => '#25D366'], + 'instagram' => ['label' => 'Instagram', 'url' => 'https://www.instagram.com/heartdevs/', 'icon' => 'fab-instagram', 'accent' => '#E4405F'], + 'github' => ['label' => 'GitHub', 'url' => 'https://github.com/he4rt', 'icon' => 'fab-github', 'accent' => '#111827', 'accent_dark' => '#FFFFFF'], + 'loja' => ['label' => 'Loja', 'url' => 'https://loja.heartdevs.com/he4rt/', 'icon' => 'heroicon-s-shopping-bag', 'accent' => '#111827', 'accent_dark' => '#FFFFFF'], + ], ]; diff --git a/config/services.php b/config/services.php index 25ec39757..b56da8e11 100644 --- a/config/services.php +++ b/config/services.php @@ -49,8 +49,9 @@ 'app' => env('TWITCH_OAUTH_SCOPES_APP', 'user:read:email'), ], 'enabled' => env('TWITCH_OAUTH_ENABLED', default: true), - 'eventsub_secret' => env('TWITCH_EVENTSUB_SECRET', 'h34rt-tw1tch-3v3ntsub-s3cr3t-k3y'), - 'eventsub_callback' => env('TWITCH_EVENTSUB_CALLBACK'), + 'eventsub_secret' => env('TWITCH_EVENTSUB_SECRET'), + 'eventsub_callback' => env('TWITCH_EVENTSUB_CALLBACK', ''), + 'broadcaster_login' => env('TWITCH_BROADCASTER_LOGIN', ''), ], 'devto' => [ diff --git a/database/migrations/2026_03_21_192332_deduplicate_external_identities.php b/database/migrations/2026_03_21_192332_deduplicate_external_identities.php index ed17acc05..f8f249366 100644 --- a/database/migrations/2026_03_21_192332_deduplicate_external_identities.php +++ b/database/migrations/2026_03_21_192332_deduplicate_external_identities.php @@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; return new class extends Migration @@ -38,9 +39,14 @@ public function up(): void ->where('external_identity_id', $dup->old_ei_id) ->update(['external_identity_id' => $dup->new_ei_id]); + // `tenant_id` only exists on pre-teardown databases; select it + // conditionally so this migration still replays after the column + // has been dropped by the multi-tenancy removal. + $hasTenantColumn = Schema::hasColumn('characters', 'tenant_id'); + $oldCharacter = DB::table('characters') ->where('user_id', $dup->old_user_id) - ->select(['experience', 'tenant_id']) + ->select($hasTenantColumn ? ['experience', 'tenant_id'] : ['experience']) ->first(); $oldXp = $oldCharacter->experience ?? 0; @@ -57,15 +63,20 @@ public function up(): void } else { logger()->warning(sprintf('Migration: Creating missing character for user %s with %s XP from old user %s', $dup->new_user_id, $oldXp, $dup->old_user_id)); - DB::table('characters')->insert([ + $newCharacter = [ 'id' => Str::uuid()->toString(), 'user_id' => $dup->new_user_id, - 'tenant_id' => $oldCharacter->tenant_id, 'experience' => $oldXp, 'reputation' => 0, 'created_at' => now(), 'updated_at' => now(), - ]); + ]; + + if ($hasTenantColumn) { + $newCharacter['tenant_id'] = $oldCharacter->tenant_id; + } + + DB::table('characters')->insert($newCharacter); } } diff --git a/database/migrations/2026_05_01_200459_delete_orphaned_tenant_records.php b/database/migrations/2026_05_01_200459_delete_orphaned_tenant_records.php index 022fc3bff..26b6fc19e 100644 --- a/database/migrations/2026_05_01_200459_delete_orphaned_tenant_records.php +++ b/database/migrations/2026_05_01_200459_delete_orphaned_tenant_records.php @@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; return new class extends Migration { @@ -42,12 +43,20 @@ public function up(): void 'tenant_users', ]; + // Guarded so a replay after multi-tenancy removal is a no-op instead of + // referencing dropped `tenant_id` columns / the dropped `tenants` table. DB::transaction(static function () use ($tables, $tenantId): void { foreach ($tables as $table) { + if (!Schema::hasColumn($table, 'tenant_id')) { + continue; + } + DB::table($table)->where('tenant_id', $tenantId)->delete(); } - DB::table('tenants')->where('id', $tenantId)->delete(); + if (Schema::hasTable('tenants')) { + DB::table('tenants')->where('id', $tenantId)->delete(); + } }); } }; diff --git a/database/migrations/2026_07_15_001020_add_state_index_to_addresses.php b/database/migrations/2026_07_15_001020_add_state_index_to_addresses.php new file mode 100644 index 000000000..6050e4e18 --- /dev/null +++ b/database/migrations/2026_07_15_001020_add_state_index_to_addresses.php @@ -0,0 +1,16 @@ + Hash::make('admin'), ]); - $he4rt = Tenant::factory() - ->for($admin, 'owner') - ->afterCreating(fn (Tenant $tenant) => $tenant->members()->attach($admin)) - ->withDiscordProvider('1442327864052547656') - ->create([ - 'name' => 'He4rt Developers', - 'slug' => 'he4rt', - 'domain' => 'he4rtdevs.test', - ]); - Character::factory() ->recycle($admin) - ->recycle($he4rt) ->createOne(); Season::factory() - ->recycle($he4rt) ->create([ 'name' => 'Season 1', 'started_at' => now()->subMonth(), @@ -51,17 +38,14 @@ public function run(): void ExternalIdentity::factory() ->recycle($admin) - ->recycle($he4rt) ->count(2) ->create(['email' => $admin->email]); Meeting::factory() ->count(2) - ->recycle($he4rt) ->create(); Message::factory() - ->recycle($he4rt) ->count(2) ->create(); } diff --git a/database/seeders/PersonalDiscordServerSeeder.php b/database/seeders/PersonalDiscordServerSeeder.php deleted file mode 100644 index 712dab22e..000000000 --- a/database/seeders/PersonalDiscordServerSeeder.php +++ /dev/null @@ -1,61 +0,0 @@ -string('he4rt.discord.guild_id'); - - if (blank($guildId)) { - $this->command->error('HE4RT_DISCORD_GUILD não está definido no .env'); - - return; - } - - if (ExternalIdentity::query()->where('external_account_id', $guildId)->exists()) { - $this->command->warn(sprintf('Guild %s já está cadastrado. Nada foi alterado.', $guildId)); - - return; - } - - $owner = User::query()->first(); - - $tenant = Tenant::factory() - ->for($owner, 'owner') - ->afterCreating(fn (Tenant $tenant) => $tenant->members()->attach($owner)) - ->create([ - 'name' => 'Servidor Pessoal', - 'slug' => 'personal', - 'active' => true, - ]); - - ExternalIdentity::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'model_type' => (new Tenant)->getMorphClass(), - 'model_id' => $tenant->getKey(), - 'type' => IdentityType::External, - 'provider' => IdentityProvider::Discord, - 'credentials_type' => CredentialsType::ApiKey, - 'credentials' => ClientAccessManager::make(), - 'external_account_id' => $guildId, - 'connected_at' => now(), - ]); - - $this->command->info('Servidor pessoal cadastrado com sucesso!'); - $this->command->info(sprintf(' Tenant: %s (ID: %s)', $tenant->name, $tenant->getKey())); - $this->command->info(' Guild ID: '.$guildId); - } -} diff --git a/docs/introduction/como-participar.md b/docs/introduction/como-participar.md index 5ca7a27b4..577dfcb7a 100644 --- a/docs/introduction/como-participar.md +++ b/docs/introduction/como-participar.md @@ -4,49 +4,39 @@ title: Como participar order: 5 --- -> ⚠️ Rascunho — revisar com a comunidade (datas/números/fatos são placeholders). - # Como participar -Entrar para a He4rt Developers é simples e gratuito. Aqui vão os primeiros -passos para fazer parte da comunidade e começar a aprender e construir junto. +Existem diferentes formas de fazer parte da comunidade. Dá uma olhada: + +## Discord -## 1. Entre no Discord +Você pode entrar no nosso servidor [clicando aqui](https://discord.gg/invite/he4rt). Lá dentro é só seguir a orientação do bot, fazer sua apresentação e pronto, você já faz parte oficialmente da comunidade. Dá para participar das reuniões semanais, aulões, spaces e muito mais. -O Discord é o ponto de encontro da comunidade. É lá que acontecem as conversas -do dia a dia, os avisos e as reuniões. -[a confirmar: link oficial de convite do servidor no Discord.] +## WhatsApp -## 2. Se apresente +Temos um grupo no WhatsApp dedicado à conversa e ao networking, para quem gosta de bater papo e viver mais de perto o dia a dia da comunidade. É uma ótima oportunidade para estar mais próximo da gente. -Depois de entrar, passe pelo canal de boas-vindas -**[a confirmar: nome do canal]** e conte um pouco sobre você: o que faz, o que -está estudando e o que te trouxe até aqui. A comunidade adora conhecer gente -nova. +A gente publica as novidades por lá antes de qualquer outro lugar. Então, se quiser saber em primeira mão, [clique aqui](https://chat.whatsapp.com/EBKjYxIodpe1x5LLExbTzK). -## 3. Participe das reuniões semanais +## Projetos -Toda semana acontecem encontros abertos a todo mundo. É uma ótima forma de -conhecer pessoas e entrar no ritmo da comunidade. Veja os detalhes na página -[Reuniões semanais](reunioes-semanais). +Na aba de Iniciativas você viu que existem várias formas de contribuir, seja ensinando, desenvolvendo ou melhorando o que já existe. É um mundo de possibilidades esperando por você. -## 4. Contribua com os projetos +E não importa se você é de programação, design, negócios, produto, segurança, teste, liderança ou qualquer outra área. Sua contribuição é muito bem-vinda, com o conhecimento que você já tem. -A He4rt mantém projetos open source feitos pela comunidade. Você pode contribuir -com código, documentação, ideias ou ajudando outras pessoas. -[a confirmar: link para os repositórios / organização no GitHub.] +Para participar, é só chamar um administrador (com a tag He4rtless no Discord) ou um administrador do grupo do WhatsApp da comunidade. -## 5. Conecte sua conta na plataforma +## Parcerias, eventos ou oportunidades -A plataforma he4rtdevs.com tem perfis, gamificação e acompanhamento da sua -participação. Você pode entrar usando sua conta do Discord para começar a somar -pontos e desbloquear conquistas. +Se você quer trazer oportunidades para a comunidade ou propor uma parceria com a He4rt, fale direto com 7K ou danielhe4rt. -## Precisa de ajuda? +## Nossas redes -Se ficar com qualquer dúvida, pergunte nos canais da comunidade — sempre tem -alguém disposto a ajudar. -[a confirmar: canal de suporte / dúvidas.] +Fica por dentro de tudo e escolhe o canal que mais combina com você: -> Os links e nomes de canais acima são placeholders. Confirme cada um com a -> comunidade antes de publicar esta página. +- [Discord](https://discord.gg/invite/he4rt) +- [WhatsApp](https://chat.whatsapp.com/EBKjYxIodpe1x5LLExbTzK) +- [X (Twitter)](https://x.com/He4rtDevs) +- [LinkedIn](https://www.linkedin.com/company/he4rt/) +- [Instagram](https://www.instagram.com/heartdevs/) +- [GitHub](https://github.com/he4rt) diff --git a/docs/introduction/iniciativas.md b/docs/introduction/iniciativas.md new file mode 100644 index 000000000..62bdafd39 --- /dev/null +++ b/docs/introduction/iniciativas.md @@ -0,0 +1,45 @@ +--- +type: introduction +title: Iniciativas +order: 3 +--- + +# Iniciativas + +Temos várias iniciativas rodando ao longo do ano, e você pode acompanhar e participar de todas quando quiser. + +## Reunião Semanal + +É aqui que compartilhamos as novidades, os projetos, as atualizações e, o mais importante, as fofocas. Acontece toda segunda-feira, às 21h. Para participar, é só subir no palco com a gente e acompanhar. + +Não precisa ligar câmera nem microfone. Na verdade, você nem precisa falar nada, só ouvir e interagir pelo chat. + +Os avisos saem tanto no Discord quanto no nosso grupo do WhatsApp. + +## Aulões + +Aqui você aprende no 0800, com gente da área, sobre os mais diversos assuntos e com exemplos práticos. Os aulões são ministrados pelos próprios membros, que submetem seu conhecimento e conteúdo para os outros, numa data fixa, geralmente às quartas-feiras, mas pode variar. + +Não precisa ligar câmera nem microfone, só ouvir e interagir pelo chat. + +Submissões de conteúdo ou de interesse devem ser combinadas com a administração. + +## Spaces + +Enquanto os aulões trazem conteúdo mais técnico, os Spaces são conversas mais amplas, que nem sempre envolvem técnica. São mais sobre orientação, dicas de carreira e assuntos assim. Às vezes convidamos membros que se destacam em suas empresas para compartilhar um pouco da experiência deles, e também pessoas de fora com relevância no mercado. + +Não precisa ligar câmera nem microfone, só ouvir e interagir pelo chat. + +Submissões de conteúdo ou de interesse devem ser combinadas com a administração. + +## Squads + +Quer contribuir com a comunidade? Quer construir projetos ou desenvolver soluções e melhorias no que fazemos aqui? Então faça parte do He4rt Squad, times organizados que trabalham diretamente nos projetos da comunidade e são responsáveis por boa parte de tudo que acontece por aqui. + +Submissões de interesse devem ser combinadas com a administração. + +## Evangelistas + +Quer contribuir mas não sabe como, ou não tem domínio técnico? Então nos ajude engajando a comunidade. Seja alguém de linha de frente: vá a eventos, curta, comente, compartilhe nas redes sociais, participe das discussões sobre o futuro da comunidade e mostre a força da He4rt por aí. + +Não exige nenhuma habilidade ou conhecimento técnico, só a vontade genuína de ajudar. diff --git a/docs/introduction/marcos-e-conquistas.md b/docs/introduction/marcos-e-conquistas.md deleted file mode 100644 index ef8e4447b..000000000 --- a/docs/introduction/marcos-e-conquistas.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -type: introduction -title: Marcos e conquistas -order: 2 ---- - -> ⚠️ Rascunho — revisar com a comunidade (datas/números/fatos são placeholders). - -# Marcos e conquistas - -A trajetória da He4rt Developers é feita de muitas mãos. Esta página reúne os -momentos que marcaram a comunidade — desde os primeiros encontros até os -projetos que hoje conectam centenas de pessoas desenvolvedoras pelo Brasil. - -## Como tudo começou - -A He4rt nasceu da vontade de criar um espaço acolhedor onde quem está começando -e quem já tem experiência pudessem aprender em conjunto, sem barreiras. O que -começou como um grupo pequeno [a confirmar: ano de fundação] foi ganhando vida a -cada conversa, live e projeto compartilhado. - -## Linha do tempo - -> Os marcos abaixo são exemplos a serem validados com quem viveu cada um deles. - -- **[a confirmar: ano]** — Primeiros encontros e formação do núcleo da - comunidade. -- **[a confirmar: ano]** — Lançamento do servidor no Discord como ponto central - de convivência. -- **[a confirmar: ano]** — Início das reuniões semanais abertas a toda a - comunidade. -- **[a confirmar: ano]** — Primeiros projetos open source mantidos pela - comunidade. -- **[a confirmar: ano]** — Construção da plataforma he4rtdevs.com e do sistema de - gamificação. - -## Em números - -> Todos os números abaixo são placeholders e precisam ser confirmados com dados -> reais antes da publicação. - -- **[a confirmar]** pessoas na comunidade. -- **[a confirmar]** reuniões semanais realizadas. -- **[a confirmar]** projetos open source publicados. -- **[a confirmar]** contribuidores ativos na plataforma. - -## O que vem por aí - -Cada conquista aqui é coletiva. Se você participou de algum desses momentos — ou -acha que falta um marco importante nesta lista — traga isso para a comunidade. -Esta página é um documento vivo e queremos contá-la com a voz de quem construiu -a He4rt. diff --git a/docs/introduction/nossos-valores.md b/docs/introduction/nossos-valores.md index 0c938903d..6fb957222 100644 --- a/docs/introduction/nossos-valores.md +++ b/docs/introduction/nossos-valores.md @@ -4,41 +4,28 @@ title: Nossos valores order: 4 --- -> ⚠️ Rascunho — revisar com a comunidade (datas/números/fatos são placeholders). - # Nossos valores -Mais do que código, a He4rt Developers é feita de pessoas. Os valores abaixo são -uma proposta de como enxergamos a convivência na comunidade — e precisam ser -revisados e aprovados por quem faz a He4rt acontecer. +Temos princípios e valores invioláveis que nos guiam e que nos trouxeram até aqui. É nosso dever, como membros, moderadores, administradores e líderes desta comunidade, repassar esses valores a todos, para que a gente nunca esqueça de onde veio e do que tem orgulho de defender. -## Acolhimento +## Aprendizado -Queremos que todo mundo se sinta em casa, independentemente do nível de -experiência, da formação ou da trajetória. Pergunta nenhuma é boba, e ninguém -deveria ter medo de pedir ajuda. +O conhecimento é a nossa base. Não importa a área: educação, tecnologia, marketing, design, negócios, cybersecurity, produto ou qualquer outra. Somos eternos aprendizes. Amamos o conhecimento e valorizamos cada compartilhamento, cada aula, cada documento ou artefato que passa por aqui. Somos guiados por uma sede insaciável de conhecimento e por uma verdade que carregamos com a gente: -## Aprender ensinando +> O conhecimento transforma e muda vidas. -Acreditamos que a melhor forma de aprender é compartilhando. Quem ensina também -aprende, e quem pergunta ajuda os próximos que terão a mesma dúvida. +## Contribuição -## Construir em conjunto +Nossa filosofia é simples. Contribuição é o caminho mais rápido para que todos alcancem seus objetivos. Sozinho você pode ir longe, mas com ajuda, e ajudando, você vai muito mais longe. Esquece a ideia de que você está sozinho, lutando por conta própria. Aqui você luta ao lado de gente com o mesmo objetivo. Ninguém fica para trás. É nosso dever ajudar o próximo com conhecimento, orientação e oportunidades. -Os projetos da comunidade são feitos a muitas mãos. Valorizamos a colaboração -aberta, o open source e o crédito a quem contribui. +## Pertencimento -## Respeito e diversidade +Você faz parte de algo muito maior do que um canal no Discord, no WhatsApp, no GitHub ou nas redes sociais. Você faz parte de uma família. Por isso costumamos nos chamar de primos e primas, porque no fundo é isso que somos: pessoas que nunca se conheceram na vida, mas que em algum momento decidiram se ajudar, construir um futuro melhor e colocar nossa bandeira nos lugares mais altos que a gente consegue imaginar. Não importa onde você esteja ou como esteja sua vida hoje. Aqui tem espaço para você. Um refúgio que acolhe e mostra como evoluir. -A He4rt é um espaço para todas as pessoas. Não há lugar para preconceito, -assédio ou qualquer forma de discriminação. -[a confirmar: link para o Código de Conduta oficial da comunidade.] +## Respeito -## Transparência +A gente não liga para gênero, cor, orientação sexual ou como você se porta. Não liga para títulos ou rótulos. O que importa é você como humano: o quanto você pode ajudar e o quanto podemos retribuir. Aqui você encontra um espaço seguro, cujo único objetivo é construir e contribuir com o máximo de respeito pelo próximo. -Decisões importantes são discutidas abertamente com a comunidade. Esta própria -documentação é um exemplo: tudo aqui pode ser revisado e melhorado. +## Crescimento -> Estes valores são um rascunho inicial. A redação final deve sair de uma -> conversa com a comunidade — possivelmente referenciando um Código de Conduta -> formal [a confirmar]. +Tudo que fazemos é para a comunidade e pela comunidade. Nossas ações, projetos, eventos, reuniões, aulões, spaces, palestras e lives se afunilam num único objetivo: crescer. Queremos elevar nossa comunidade, trazer oportunidades, mostrar caminhos e espalhar nossa palavra pelo Brasil e pelo mundo, com aquela sede insaciável de sempre, para que todo mundo conheça a He4rt. diff --git a/docs/introduction/o-que-e-a-he4rt.md b/docs/introduction/o-que-e-a-he4rt.md index 656ac1598..cfae4a338 100644 --- a/docs/introduction/o-que-e-a-he4rt.md +++ b/docs/introduction/o-que-e-a-he4rt.md @@ -6,7 +6,22 @@ order: 1 # O que é a He4rt -A He4rt Developers é uma comunidade brasileira de pessoas desenvolvedoras que -se reúne para aprender, ensinar e construir projetos em conjunto. Este portal -documenta como a plataforma funciona, das decisões de arquitetura ao dia a dia -de cada módulo. +A He4rt nasceu em 2018, numa live do Daniel Reis. Ele decidiu reunir pessoas apaixonadas por programação para ensinar, construir e desenvolver projetos juntos. Aquele grupo de amigos virou, com o tempo, uma das maiores comunidades de tecnologia da América Latina. Hoje somos a maior comunidade de tecnologia open source do Brasil. + +Nosso objetivo é ser um hub de oportunidades. Conectar pessoas, ensinar, compartilhar e aprender. Somos apaixonados por tecnologia e por tudo que ela pode transformar. + +## Nossa linha do tempo + +**2018.** A He4rt nasce. É criado o servidor oficial da comunidade. + +**2019.** Lançamos o **4noob**, um repositório gratuito que ensinava tecnologia e programação do absoluto zero até o nível intermediário, antes mesmo da popularização da IA. Todo o conteúdo foi produzido pela própria comunidade e se tornou um dos maiores acervos gratuitos da época. Até hoje é nosso maior case de sucesso. + +**2020.** Organizamos o Microsoft 365 Bootcamp através dos canais da He4rt Developers, cuidando de toda a produção do evento. + +**2021.** Fizemos a maior live de tecnologia do ano: nossa primeira conferência, a **He4rt Conf 2021**, com mais de 42 mil espectadores únicos. + +**2022.** Nosso primeiro meetup presencial, em parceria com a 44SP, uma das maiores faculdades de tecnologia da América Latina, com apoio da Microsoft, Accusense, Código Falado e Breno Mazieiro. + +**2026.** Chegamos aos 25 mil membros no Discord. Entramos numa fase de crescimento acelerado com o desenvolvimento da nossa própria plataforma, a He4rt Hub. Também firmamos parceria com a 3Pontos para o LaravelDaySP, trazendo ao Brasil dois representantes do core team do Laravel, o maior framework PHP do mundo, com quase 77 mil espectadores únicos na Twitch. + +E isso é só o começo. diff --git a/docs/introduction/reunioes-semanais.md b/docs/introduction/reunioes-semanais.md deleted file mode 100644 index 628b972ae..000000000 --- a/docs/introduction/reunioes-semanais.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -type: introduction -title: Reuniões semanais -order: 3 ---- - -> ⚠️ Rascunho — revisar com a comunidade (datas/números/fatos são placeholders). - -# Reuniões semanais - -As reuniões semanais são um dos corações pulsantes da He4rt Developers. É o -momento em que a comunidade se encontra para conversar, compartilhar -aprendizados, mostrar projetos e simplesmente estar junto. - -## Quando acontecem - -As reuniões acontecem toda semana, **[a confirmar: dia da semana]** às -**[a confirmar: horário, fuso de Brasília]**. O encontro é aberto a qualquer -pessoa da comunidade — não importa se você é iniciante ou já trabalha na área há -anos. - -## Onde acontecem - -Os encontros são realizados no **[a confirmar: canal de voz do Discord / outra -plataforma]**. O link e os lembretes são divulgados nos canais da comunidade -antes de cada reunião. - -## Como costuma ser - -> O formato abaixo é uma descrição plausível a ser validada com quem organiza os -> encontros. - -- **Boas-vindas e novidades** — atualizações da comunidade e recados. -- **Tema da semana** — alguém apresenta um assunto, projeto ou aprendizado. -- **Conversa aberta** — espaço para dúvidas, trocas e papo livre. -- **Próximos passos** — combinados e ideias para a semana seguinte. - -## Como participar - -Não é preciso se inscrever nem preparar nada. Basta entrar no canal no horário -combinado, ligar (ou não) o microfone e participar no seu ritmo. Quem preferir -só ouvir também é muito bem-vindo. - -> Se você organiza ou participa das reuniões, ajude a confirmar dia, horário, -> plataforma e formato para que esta página reflita a realidade da comunidade. diff --git a/package-lock.json b/package-lock.json index 72e833fba..b0a3b35fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,16 +12,17 @@ "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", - "@tailwindcss/vite": "4.3.2", - "concurrently": "10.0.3", + "@tailwindcss/vite": "4.3.3", + "concurrently": "10.0.4", "husky": "9.1.7", - "laravel-vite-plugin": "3.1.0", - "lint-staged": "17.0.8", + "laravel-vite-plugin": "3.1.3", + "lint-staged": "17.2.0", "npm-check-updates": "22.2.9", - "prettier": "3.9.4", - "tailwindcss": "4.3.2", + "prettier": "3.9.6", + "tailwindcss": "4.3.3", + "terser": "5.49.0", "tw-animate-css": "1.4.0", - "vite": "8.1.4" + "vite": "8.1.5" }, "engines": { "node": "^24" @@ -33,7 +34,6 @@ "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -45,7 +45,6 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -92,6 +91,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -427,49 +437,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -484,9 +494,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -501,9 +511,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -518,9 +528,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -535,9 +545,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -552,9 +562,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -569,9 +579,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -586,9 +596,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -603,9 +613,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -620,9 +630,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -650,9 +660,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -667,9 +677,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -697,15 +707,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -722,20 +732,17 @@ "tslib": "^2.4.0" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "dependencies": { - "environment": "^1.0.0" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.4.0" } }, "node_modules/ansi-regex": { @@ -764,6 +771,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -777,39 +791,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -825,52 +806,23 @@ "node": ">=20" } }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } + "license": "MIT" }, "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", + "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -917,9 +869,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -930,19 +882,6 @@ "node": ">=10.13.0" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -953,13 +892,6 @@ "node": ">=6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1039,22 +971,6 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1066,9 +982,9 @@ } }, "node_modules/laravel-vite-plugin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz", - "integrity": "sha512-Fzocl+X4eQ9jOi0RwdphYRGkUbPJ3ky1pTAST5Ot18cS2gw6d2vldK2eCrlKDVjtibCjCx5qptYDlA0373n7qg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz", + "integrity": "sha512-cI5Anw4QHY+UzvZczFaj+j8NhwT2FtyEN8aqS/hOdt6DpEFBsn6x3GENxALem3cc+TsGvd9MacneimEvShvKMA==", "dev": true, "license": "MIT", "dependencies": { @@ -1083,7 +999,7 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "fontaine": "^0.5.0", + "fontaine": "^0.8.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -1354,14 +1270,13 @@ } }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.2.0.tgz", + "integrity": "sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, @@ -1378,96 +1293,6 @@ "yaml": "^2.9.0" } }, - "node_modules/listr2": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", - "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1478,23 +1303,10 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1525,22 +1337,6 @@ "npm": ">=10.0.0" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1562,9 +1358,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1582,7 +1378,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1605,9 +1401,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -1620,30 +1416,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1689,9 +1461,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -1701,34 +1473,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=0.10.0" } }, "node_modules/source-map-js": { @@ -1741,6 +1493,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -1752,17 +1515,18 @@ } }, "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1798,12 +1562,11 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -1819,6 +1582,25 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -1881,17 +1663,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -1984,18 +1765,18 @@ } }, "node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -2055,24 +1836,6 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 4b678305b..0ceaf5f33 100644 --- a/package.json +++ b/package.json @@ -19,16 +19,17 @@ "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", - "@tailwindcss/vite": "4.3.2", - "concurrently": "10.0.3", + "@tailwindcss/vite": "4.3.3", + "concurrently": "10.0.4", "husky": "9.1.7", - "laravel-vite-plugin": "3.1.0", - "lint-staged": "17.0.8", + "laravel-vite-plugin": "3.1.3", + "lint-staged": "17.2.0", "npm-check-updates": "22.2.9", - "prettier": "3.9.4", - "tailwindcss": "4.3.2", + "prettier": "3.9.6", + "tailwindcss": "4.3.3", + "terser": "5.49.0", "tw-animate-css": "1.4.0", - "vite": "8.1.4" + "vite": "8.1.5" }, "lint-staged": { "*.{js,ts,json,yml,md,css}": [ diff --git a/public/images/3pontos/3pontos-ball.svg b/public/images/3pontos/3pontos-ball.svg deleted file mode 100644 index 5c76b802c..000000000 --- a/public/images/3pontos/3pontos-ball.svg +++ /dev/null @@ -1,310 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/3pontos/3pontos-map.png b/public/images/3pontos/3pontos-map.png deleted file mode 100644 index 296d0fa4f..000000000 Binary files a/public/images/3pontos/3pontos-map.png and /dev/null differ diff --git a/public/images/3pontos/donut.svg b/public/images/3pontos/donut.svg deleted file mode 100644 index b5773f3be..000000000 --- a/public/images/3pontos/donut.svg +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/3pontos/grids-horizontal.svg b/public/images/3pontos/grids-horizontal.svg deleted file mode 100644 index 453b54849..000000000 --- a/public/images/3pontos/grids-horizontal.svg +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/3pontos/logo-chain.png b/public/images/3pontos/logo-chain.png deleted file mode 100644 index cbb53b85d..000000000 Binary files a/public/images/3pontos/logo-chain.png and /dev/null differ diff --git a/public/images/3pontos/logo-creation.png b/public/images/3pontos/logo-creation.png deleted file mode 100644 index 14141c140..000000000 Binary files a/public/images/3pontos/logo-creation.png and /dev/null differ diff --git a/public/images/3pontos/logo-rounded.svg b/public/images/3pontos/logo-rounded.svg deleted file mode 100644 index ccdb9b503..000000000 --- a/public/images/3pontos/logo-rounded.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/3pontos/logo.svg b/public/images/3pontos/logo.svg deleted file mode 100644 index f38220996..000000000 --- a/public/images/3pontos/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/images/3pontos/partners/betel-logo.png b/public/images/3pontos/partners/betel-logo.png deleted file mode 100644 index ad496465c..000000000 Binary files a/public/images/3pontos/partners/betel-logo.png and /dev/null differ diff --git a/public/images/3pontos/partners/firece-logo.svg b/public/images/3pontos/partners/firece-logo.svg deleted file mode 100644 index b29f82b2a..000000000 --- a/public/images/3pontos/partners/firece-logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/images/3pontos/partners/flamma-logo.svg b/public/images/3pontos/partners/flamma-logo.svg deleted file mode 100644 index 7362c43b9..000000000 --- a/public/images/3pontos/partners/flamma-logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/images/3pontos/partners/ipe-logo.svg b/public/images/3pontos/partners/ipe-logo.svg deleted file mode 100644 index 0d1f50956..000000000 --- a/public/images/3pontos/partners/ipe-logo.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/public/images/3pontos/wave.svg b/public/images/3pontos/wave.svg deleted file mode 100644 index 1c9bb9c09..000000000 --- a/public/images/3pontos/wave.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/assets/linus-dont-ask-to-ask.webp b/public/images/assets/linus-dont-ask-to-ask.webp new file mode 100644 index 000000000..d4c4b42d6 Binary files /dev/null and b/public/images/assets/linus-dont-ask-to-ask.webp differ diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css index d6f968e49..8be35f60d 100644 --- a/resources/css/filament/app/theme.css +++ b/resources/css/filament/app/theme.css @@ -49,6 +49,16 @@ } } +/* Force image editor save button to use primary (purple) instead of success (green) */ +.fi-fo-file-upload-editor-control-panel-footer .fi-btn.fi-color-success { + background-color: var(--primary-600) !important; + color: #fff !important; + + &:hover { + background-color: var(--primary-500) !important; + } +} + /* Keep user avatar circle visible against dark sidebar (default ui-avatars uses gray-950, which blends into dark mode) */ .fi-sidebar .fi-user-avatar { @@ -60,3 +70,13 @@ .fi-sidebar { @apply border-e border-gray-200 dark:border-white/10; } + +/* Make "today" stand out in date pickers (esp. dark mode) */ +.fi-fo-date-time-picker-calendar-day.fi-fo-date-time-picker-calendar-day-today:not(.fi-selected):not(.fi-disabled) { + @apply font-semibold text-primary-600 ring-1 ring-inset ring-primary-500/50 dark:text-primary-300 dark:ring-primary-400/60; +} + +/* Calendar suffix icon should feel clickable */ +.fi-fo-date-time-picker .fi-input-wrp-suffix { + @apply cursor-pointer; +} diff --git a/resources/views/livewire/connection-hub.blade.php b/resources/views/livewire/connection-hub.blade.php index a419c0789..37d82914e 100644 --- a/resources/views/livewire/connection-hub.blade.php +++ b/resources/views/livewire/connection-hub.blade.php @@ -145,49 +145,69 @@ class="rounded bg-gray-100 px-1 py-0.5 font-mono text-[9px] text-gray-500 ring-1 {{-- Merge Confirmation Modal --}} @if ($showMergeModal && $mergeTarget) -
+ @teleport('body') diff --git a/tests/.pest/shards.json b/tests/.pest/shards.json index fa7917d70..ff54ca8ab 100644 --- a/tests/.pest/shards.json +++ b/tests/.pest/shards.json @@ -1,161 +1,180 @@ { "timings": { - "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 2.095, - "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 0.8115, - "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 1.1597, - "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 1.1538, - "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 0.5821, - "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 0.5406, - "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 0.5695, - "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 0.7663, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 0.8555, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 0.4472, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 0.6389, - "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 0.1645, - "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 0.5703, - "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.1508, - "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 0.1945, - "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 0.4563, - "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.2335, - "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.2337, - "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.5523, - "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.5776, - "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 5.7378, - "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.5881, - "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.3027, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.0536, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.1009, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.301, - "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 0.6902, - "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.4961, - "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 1.8584, - "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 0.9489, - "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.4992, - "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.2006, - "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.4947, - "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 0.9454, - "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.3025, - "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.4243, - "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.3033, - "Appmodules\\economy\\tests\\Unit\\CreditTest": 0.1546, - "Appmodules\\economy\\tests\\Unit\\DebitTest": 0.3025, - "Appmodules\\economy\\tests\\Unit\\TransferTest": 0.4794, - "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\EventResourceTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 0.5448, - "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 0.5448, - "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 0.5448, - "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.5448, - "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.5448, - "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.704, - "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 2.0406, - "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 0.7491, - "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.98, - "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.5563, - "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 1.5236, - "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.3237, - "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 1.773, - "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 0.8692, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1746, - "Appmodules\\integrationdevto\\tests\\Feature\\SyncDevToArticlesTest": 0.404, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 6.8537, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 2.2039, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 2.92, - "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1222, - "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.177, - "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 2.2978, - "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 11.8086, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1482, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2467, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1543, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1472, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1466, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.1051, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0979, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1463, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1469, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.1053, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0985, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.1462, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0975, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.549, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.2128, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.8143, - "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.2886, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.4922, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.6587, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 1.3299, - "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.413, - "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.5222, - "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.9796, - "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.4336, - "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.6951, - "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 1.077, - "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 1.3777, - "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 1.2935, - "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.8392, - "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.5974, - "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.5618, - "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 3.968, - "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.7962, - "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 1.2011, - "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 1.8584, - "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 1.0644, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.4463, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.3201, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.3239, - "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 2.0921, - "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 0.4152, - "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.3993, - "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.3526, - "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 0.2867, - "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 2.1791, - "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.2098, - "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 0.8256, - "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.5649, - "Appmodules\\paneladmin\\tests\\Feature\\ApplyTenantScopesTest": 0.266, - "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 1.301, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.2831, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 3.4181, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 2.8877, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 2.5896, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 1.1423, - "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 0.5448, - "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.5448, - "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 0.5448, - "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 6.1316, - "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 1.6969, - "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 1.3169, - "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.5669, - "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.2566, - "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.122, - "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.636, - "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.6384, - "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.4501, - "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.3042, - "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 1.2219, - "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.7305, - "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 1.8365, - "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 5.2888, - "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.258, - "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0502, - "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.1017, - "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1599, - "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.3199, - "Tests\\Feature\\AddressTest": 0.5364, - "Tests\\Feature\\Geo\\GeoLocationTest": 0.4048, - "Tests\\Feature\\LocaleSwitcherTest": 0.5448 + "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 5.438, + "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 5.653, + "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 6.0604, + "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 5.9881, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 5.3923, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 5.4171, + "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 5.4119, + "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 5.5709, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 5.388, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 5.2705, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 5.4434, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 4.9252, + "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 5.2209, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.3568, + "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 4.8662, + "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 5.3035, + "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.1835, + "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 4.1908, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 4.4401, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.565, + "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 4.3953, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.4504, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.2536, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\ConfigureEmpresarialRoomActionTest": 0.5551, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.2453, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.315, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.4874, + "Appmodules\\botdiscord\\tests\\Unit\\Enums\\EmpresarialRejectionReasonTest": 0.338, + "Appmodules\\botdiscord\\tests\\Unit\\Events\\WelcomeMemberGuildIconTest": 0.4035, + "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 5.4053, + "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.6391, + "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 0.935, + "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 1.0553, + "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.6784, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.3933, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.6524, + "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 0.9631, + "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.5018, + "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.5591, + "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.3518, + "Appmodules\\economy\\tests\\Unit\\CreditTest": 4.9328, + "Appmodules\\economy\\tests\\Unit\\DebitTest": 4.9478, + "Appmodules\\economy\\tests\\Unit\\TransferTest": 4.9688, + "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 1.219, + "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 45.8109, + "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.6967, + "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 1.3989, + "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.4182, + "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 3.5382, + "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 1.3107, + "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.7833, + "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.6208, + "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 1.6052, + "Appmodules\\events\\tests\\Feature\\EventResourceTest": 3.2068, + "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 2.4185, + "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 1.5898, + "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 1.9344, + "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 4.8469, + "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.6188, + "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.2886, + "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.5647, + "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 6.3331, + "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 5.1418, + "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.5633, + "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.7239, + "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 0.9715, + "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.3039, + "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 1.7794, + "Appmodules\\identity\\tests\\Feature\\Auth\\OAuthControllerTest": 0.165, + "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 4.7357, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1849, + "Appmodules\\integrationdevto\\tests\\Feature\\SyncDevToArticlesTest": 0.4383, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 4.515, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 1.9195, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 2.7987, + "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1552, + "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.1851, + "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 1.4227, + "Appmodules\\integrationdiscord\\tests\\Unit\\Enums\\DiscordEventTypeTest": 0.0959, + "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 12.1636, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1726, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2292, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1658, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1547, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.0931, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.0613, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0555, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.0828, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.0801, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.0556, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0519, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.0843, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0562, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.3947, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.1827, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.3948, + "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.3398, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.2545, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.3304, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 0.999, + "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.2324, + "Appmodules\\integrationtwitch\\tests\\Feature\\RegisterTwitchSubscriptionsActionTest": 0.201, + "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.5374, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchAppTokenServiceTest": 0.1595, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.7294, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.5916, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.9335, + "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 1.0991, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 1.3761, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 1.3462, + "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.6843, + "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.7281, + "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.7601, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 3.3103, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.6902, + "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 1.2289, + "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 1.586, + "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 1.0774, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.4357, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.374, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.3458, + "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 1.8932, + "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 4.3806, + "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.205, + "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.1819, + "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 4.1081, + "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 6.0206, + "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.1087, + "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 4.6916, + "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.6084, + "Appmodules\\paneladmin\\tests\\Feature\\Discord\\DiscordClusterSmokeTest": 2.3111, + "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 0.9081, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\CommunityActivityStatsTest": 0.3695, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\LocationDashboardTest": 0.1621, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MeetingShowcasePageTest": 1.3113, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MembersByStateTest": 0.382, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.2993, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 2.5558, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 2.4277, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 2.1165, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 0.7781, + "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\ConnectTwitchActionTest": 0.39, + "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\RegisterSubscriptionsActionTest": 0.286, + "Appmodules\\panelapp\\tests\\Feature\\Auth\\LastAuthProviderTest": 0.608, + "Appmodules\\panelapp\\tests\\Feature\\ConnectionHubTest": 0.2006, + "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 1.3402, + "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.4423, + "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 1.8104, + "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 3.8524, + "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 1.0706, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 0.8452, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.2094, + "Appmodules\\portal\\tests\\Feature\\HeroAvatarsTest": 0.8082, + "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.3304, + "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.1073, + "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.579, + "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.3673, + "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.3593, + "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.2544, + "Appmodules\\profile\\tests\\Feature\\SkillSearchTest": 0.162, + "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 0.915, + "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.5595, + "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 1.216, + "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 2.639, + "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.1376, + "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0276, + "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.0565, + "Appmodules\\profile\\tests\\Unit\\SocialPlatformTest": 0.396, + "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1012, + "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.1698, + "Tests\\Feature\\AddressTest": 4.3728, + "Tests\\Feature\\ConnectionHubMergeModalTest": 3.8064, + "Tests\\Feature\\Geo\\GeoLocationTest": 0.3028, + "Tests\\Feature\\LocaleSwitcherTest": 0.2809 }, - "checksum": "8a07148d596627eadecf191913c62e76", - "updated_at": "2026-07-09T17:13:52+00:00" + "checksum": "3552aea7c18e5ccefedff7d5eda788c3", + "updated_at": "2026-08-13T17:03:48+00:00" } diff --git a/tests/Feature/ConnectionHubMergeModalTest.php b/tests/Feature/ConnectionHubMergeModalTest.php new file mode 100644 index 000000000..7dfb3136b --- /dev/null +++ b/tests/Feature/ConnectionHubMergeModalTest.php @@ -0,0 +1,53 @@ +create([ + 'username' => 'discord-user', + ]); + + $currentUser = User::factory()->create([ + 'username' => 'github-user', + ]); + + ExternalIdentity::factory()->create([ + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $keptUser->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-123', + ]); + + $this->actingAs($currentUser); + Filament::setCurrentPanel(Filament::getPanel('app')); + + session()->put('oauth_merge_pending', [ + 'conflicting_user_id' => $keptUser->id, + 'provider' => IdentityProvider::Discord->value, + 'credentials' => [], + 'oauth_user' => [ + 'provider_id' => 'discord-123', + 'username' => 'discord-user', + 'name' => 'Discord User', + 'email' => null, + 'avatar_url' => null, + ], + ]); + + livewire(ConnectionHub::class) + ->assertSee('Conta existente encontrada') + ->assertSee('@ discord-user') + ->assertSee('@ github-user') + ->assertSee('será mantida como principal') + ->assertSee('será absorvida e removida') + ->assertSee('histórico já associado à conta mantida será preservado') + ->assertSee('será autenticado novamente'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 09debe75f..53411164c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,30 +4,9 @@ namespace Tests; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { use CreatesApplication; - - protected function actingAsAdmin(): self - { - Tenant::factory() - ->afterCreating(static function (Tenant $tenant): void { - ExternalIdentity::factory([ - 'tenant_id' => $tenant->getKey(), - 'provider' => 'discord', - 'external_account_id' => '123', - ])->create(); - }) - ->create(); - - return $this->withHeaders([ - 'X-He4rt-Authorization' => config('he4rt.server_key'), - 'X-He4rt-Provider' => 'discord', - 'X-He4rt-Provider-Id' => '123', - ]); - } } diff --git a/vite.config.js b/vite.config.js index 8278b70f2..dd1e945aa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -22,4 +22,19 @@ export default defineConfig({ server: { cors: true, }, + build: { + minify: 'terser', + cssMinify: true, + chunkSizeWarningLimit: 1600, + rolldownOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules')) { + // Creates chunks based on the package name + return id.toString().split('node_modules/')[1].split('/')[0].toString(); + } + }, + }, + }, + }, });