From 8143dc5903d35b4620099ef4168003d02a1607eb Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Wed, 22 Jul 2026 00:30:44 -0300 Subject: [PATCH 1/2] feat(community): retrospectiva persistida com snapshot ao publicar (Fase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Torna a retrospectiva uma peça editorial persistida: o operador cura e publica, o visitante só assiste ao resultado congelado. Domínio (community): - Entidade Retrospective (tabela community_retrospectives, sem tenant_id) + enum RetrospectiveStatus (draft|publishing|published, contratos Filament). - VOs DeckConfig e RetrospectiveSnapshot com casts tipados (AsDeckConfig, AsRetrospectiveSnapshot), nunca array solto. - FrozenSlide: reidrata o snapshot honrando o contrato Slide sem o domínio importar as classes de slide de integration (Domain -> Integration proibido). - Actions CompileSnapshot (coleta+congela) e ComposeDeck (aplica curadoria sobre o snapshot), PublishRetrospective + job CompileRetrospectiveSnapshot (congela na fila). - label() novo no contrato RetrospectiveSource (identidade estática das fontes). Portal: - RetrospectiveDeck removido; a orquestracao migrou para o dominio. - Pagina publica le a edicao publicada mais recente (sem filtros do visitante); rota de preview /comunidade/retrospectiva/{retrospective}/preview (guard 403 no mount, o portal nao tem rota de login web). panel-admin: - RetrospectiveResource: CRUD completo em capacidade (ordem/on-off de fonte via repeater, exclusions, textos, publicar). Preview e pagina publica compartilham o mesmo render path (ComposeDeck). Docs: spec e ADR-0002 atualizados com os refinamentos (FrozenSlide, label(), exclusions recompilam o snapshot). --- .../src/Retrospective/DiscordSource.php | 7 +- .../factories/RetrospectiveFactory.php | 49 ++++++ ..._create_community_retrospectives_table.php | 41 +++++ ...iva-persistida-com-snapshot-ao-publicar.md | 22 +++ .../2026-07-19-retrospectiva-multi-fonte.md | 6 +- .../src/CommunityServiceProvider.php | 16 +- .../Retrospective/Actions/CompileSnapshot.php | 49 ++++++ .../src/Retrospective/Actions/ComposeDeck.php | 60 +++++++ .../Actions/PublishRetrospective.php | 24 +++ .../src/Retrospective/Casts/AsDeckConfig.php | 36 +++++ .../Casts/AsRetrospectiveSnapshot.php | 47 ++++++ .../Contracts/RetrospectiveSource.php | 7 + .../src/Retrospective/DTOs/DeckConfig.php | 129 ++++++++++++++++ .../DTOs/RetrospectiveSnapshot.php | 133 ++++++++++++++++ .../Enums/RetrospectiveStatus.php | 72 +++++++++ .../Jobs/CompileRetrospectiveSnapshot.php | 66 ++++++++ .../Retrospective/Models/Retrospective.php | 108 +++++++++++++ .../src/Retrospective/Slides/FrozenSlide.php | 39 +++++ .../PublishRetrospectiveTest.php | 67 ++++++++ .../Retrospective/RetrospectiveModelTest.php | 70 +++++++++ .../Retrospective/CompileSnapshotTest.php | 77 +++++++++ .../Unit/Retrospective/ComposeDeckTest.php | 57 +++++++ .../Unit/Retrospective/DeckConfigTest.php | 57 +++++++ .../RetrospectiveSnapshotTest.php | 58 +++++++ .../Retrospective/RetrospectiveStatusTest.php | 20 +++ .../src/Retrospective/GithubSource.php | 9 +- .../Actions/PublishRetrospectiveAction.php | 45 ++++++ .../Pages/CreateRetrospective.php | 26 ++++ .../Pages/EditRetrospective.php | 68 ++++++++ .../Pages/ListRetrospectives.php | 24 +++ .../Retrospectives/RetrospectiveResource.php | 61 ++++++++ .../Schemas/RetrospectiveForm.php | 117 ++++++++++++++ .../Support/AvailableSources.php | 32 ++++ .../Retrospectives/Support/DeckConfigForm.php | 145 +++++++++++++++++ .../Tables/RetrospectivesTable.php | 66 ++++++++ .../src/PanelAdminServiceProvider.php | 3 + .../RetrospectiveResourceTest.php | 78 ++++++++++ .../views/community-retrospective.blade.php | 21 ++- .../views/components/retro/controls.blade.php | 51 ------ .../views/components/retro/deck.blade.php | 10 +- .../components/retro/slides/closing.blade.php | 8 +- .../components/retro/slides/cover.blade.php | 16 +- .../Livewire/CommunityRetrospectivePage.php | 89 ++++++----- .../portal/src/PortalServiceProvider.php | 18 +-- .../src/Retrospective/RetrospectiveDeck.php | 72 --------- .../CommunityRetrospectivePageTest.php | 146 +++++++++--------- .../tests/Feature/RetrospectiveDeckTest.php | 73 --------- 47 files changed, 2156 insertions(+), 339 deletions(-) create mode 100644 app-modules/community/database/factories/RetrospectiveFactory.php create mode 100644 app-modules/community/database/migrations/2026_07_22_025934_create_community_retrospectives_table.php create mode 100644 app-modules/community/src/Retrospective/Actions/CompileSnapshot.php create mode 100644 app-modules/community/src/Retrospective/Actions/ComposeDeck.php create mode 100644 app-modules/community/src/Retrospective/Actions/PublishRetrospective.php create mode 100644 app-modules/community/src/Retrospective/Casts/AsDeckConfig.php create mode 100644 app-modules/community/src/Retrospective/Casts/AsRetrospectiveSnapshot.php create mode 100644 app-modules/community/src/Retrospective/DTOs/DeckConfig.php create mode 100644 app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php create mode 100644 app-modules/community/src/Retrospective/Enums/RetrospectiveStatus.php create mode 100644 app-modules/community/src/Retrospective/Jobs/CompileRetrospectiveSnapshot.php create mode 100644 app-modules/community/src/Retrospective/Models/Retrospective.php create mode 100644 app-modules/community/src/Retrospective/Slides/FrozenSlide.php create mode 100644 app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php create mode 100644 app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php create mode 100644 app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php create mode 100644 app-modules/community/tests/Unit/Retrospective/ComposeDeckTest.php create mode 100644 app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php create mode 100644 app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php create mode 100644 app-modules/community/tests/Unit/Retrospective/RetrospectiveStatusTest.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Actions/PublishRetrospectiveAction.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/CreateRetrospective.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/EditRetrospective.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/ListRetrospectives.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/RetrospectiveResource.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Schemas/RetrospectiveForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/AvailableSources.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Retrospectives/Tables/RetrospectivesTable.php create mode 100644 app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php delete mode 100644 app-modules/portal/resources/views/components/retro/controls.blade.php delete mode 100644 app-modules/portal/src/Retrospective/RetrospectiveDeck.php delete mode 100644 app-modules/portal/tests/Feature/RetrospectiveDeckTest.php diff --git a/app-modules/activity/src/Retrospective/DiscordSource.php b/app-modules/activity/src/Retrospective/DiscordSource.php index 750cb1410..bdc9eca67 100644 --- a/app-modules/activity/src/Retrospective/DiscordSource.php +++ b/app-modules/activity/src/Retrospective/DiscordSource.php @@ -42,6 +42,11 @@ public function key(): string return 'discord'; } + public function label(): string + { + return 'Discord'; + } + public function collect(Period $period, SourceFilters $filters): SourceResult { $totalMessages = $this->messages($period, $filters)->count(); @@ -73,7 +78,7 @@ public function collect(Period $period, SourceFilters $filters): SourceResult return new SourceResult( key: $this->key(), - label: 'Discord', + label: $this->label(), headline: $this->headline($totalMessages, $participants, $joins, $totalReactions), slides: $this->slides( participants: $participants, diff --git a/app-modules/community/database/factories/RetrospectiveFactory.php b/app-modules/community/database/factories/RetrospectiveFactory.php new file mode 100644 index 000000000..8edd9262f --- /dev/null +++ b/app-modules/community/database/factories/RetrospectiveFactory.php @@ -0,0 +1,49 @@ + + */ +final class RetrospectiveFactory extends Factory +{ + protected $model = Retrospective::class; + + public function definition(): array + { + $since = CarbonImmutable::now()->subMonth()->startOfDay(); + + return [ + 'id' => fake()->uuid(), + 'title' => 'Retrospectiva de '.fake()->monthName(), + 'since' => $since, + 'until' => CarbonImmutable::now(), + 'status' => RetrospectiveStatus::Draft, + 'cover_title' => fake()->sentence(3), + 'cover_intro' => fake()->sentence(), + 'closing_text' => fake()->sentence(), + 'hide_bots' => true, + 'deck_config' => new DeckConfig(), + 'snapshot' => null, + 'published_at' => null, + ]; + } + + public function published(?RetrospectiveSnapshot $snapshot = null): self + { + return $this->state(fn (): array => [ + 'status' => RetrospectiveStatus::Published, + 'published_at' => CarbonImmutable::now(), + 'snapshot' => $snapshot ?? new RetrospectiveSnapshot(), + ]); + } +} diff --git a/app-modules/community/database/migrations/2026_07_22_025934_create_community_retrospectives_table.php b/app-modules/community/database/migrations/2026_07_22_025934_create_community_retrospectives_table.php new file mode 100644 index 000000000..c984a0b28 --- /dev/null +++ b/app-modules/community/database/migrations/2026_07_22_025934_create_community_retrospectives_table.php @@ -0,0 +1,41 @@ +uuid('id')->primary(); + $table->string('title'); + $table->timestampTz('since'); + $table->timestampTz('until'); + $table->string('status', 20)->default(RetrospectiveStatus::Draft->value)->comment(RetrospectiveStatus::stringifyCases()); + $table->string('cover_title')->nullable(); + $table->text('cover_intro')->nullable(); + $table->text('closing_text')->nullable(); + $table->boolean('hide_bots')->default(value: true); + // Curadoria de apresentação (ordem, on/off, exclusions). Cast AsDeckConfig + // normaliza null para um DeckConfig vazio na leitura. + $table->jsonb('deck_config')->nullable(); + // SourceResult[] congelados no publish; null enquanto rascunho. + $table->jsonb('snapshot')->nullable(); + $table->timestampTz('published_at')->nullable(); + $table->timestampsTz(); + + // Página pública busca a edição publicada mais recente. + $table->index(['status', 'published_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('community_retrospectives'); + } +}; diff --git a/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md b/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md index c3aea5f91..130a11af7 100644 --- a/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md +++ b/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md @@ -76,3 +76,25 @@ O custo é proporcional à janela de datas, não ao total, porque há índice na `snapshot + config`), então "ver rascunho" bate com o publicado. - **Divergência consciente da spec-base:** ela era toda "per tenant" e centrada em `Cadence`. Aqui não há tenancy (removida no #413) e o período é datas livres. + +## Notas de implementação (Fase 2) + +Detalhes que emergiram na implementação, dentro do que foi decidido acima: + +- **`FrozenSlide` em vez de registro `kind -> classe`.** O snapshot reidrata cada slide como um `Slide` + genérico (`FrozenSlide`, dono no `community`, carrega `kind` + props). Um registro `kind -> classe` + exigiria o domínio `community` conhecer as classes de slide de `integration-github` (Domain -> Integration, + proibido). Como a renderização é por convenção `kind -> Blade` sobre `toArray()`, o contrato `Slide` + basta — o tipo concreto do slide só importa na produção do dado (no `collect()`), não no consumo do + snapshot. +- **`label()` no contrato `RetrospectiveSource`.** A identidade da fonte (key + label) virou estática para + o CRUD listar/ordenar as fontes sem coletar dado. Antes o label era hardcoded dentro do `collect()`. +- **Exclusions recompilam; ordem/on-off re-derivam.** Ordem e on/off (fonte/slide) são curadoria de + apresentação: aplicadas na composição (`ComposeDeck`) sobre o snapshot congelado, baratas. Exclusions + mexem no DADO (entram no `SourceFilters` do `collect`, ADR-0001), então alterá-las só reflete numa nova + publicação (recompila o snapshot) — não são reaplicadas na composição. +- **`publishing` é um estado transitório do enum.** `RetrospectiveStatus` tem `draft | publishing | + published`; `publishing` cobre a janela em que o job congela o snapshot. +- **Preview autenticado sem rota de login web.** O portal não tem rota `login` nomeada, então o guard do + preview vive no `mount()` do componente (`abort_unless(auth()->check(), 403)`), não num middleware `auth` + que dependeria da rota inexistente. diff --git a/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md b/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md index 5f7f2ced1..7ccef6545 100644 --- a/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md +++ b/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md @@ -51,6 +51,7 @@ Partimos de uma spec-base que foi destrinchada contra o projeto de hoje. Três p ``` interface RetrospectiveSource { key(): string + label(): string // identidade estática (CRUD lista/ordena sem coletar) collect(Period, SourceFilters): SourceResult } @@ -114,7 +115,10 @@ Retrospective (community · tabela community_retrospectives) ``` `deck_config` e `snapshot` são VOs tipados via cast dedicado (regra de typed-json do repo), nunca `array` -solto. A reidratação dos `Slide` tipados usa um registro `kind -> classe` no cast do snapshot. +solto. Ao reidratar o snapshot, cada slide volta como um `FrozenSlide` (Slide genérico do `community` +que carrega `kind` + props) em vez de reinstanciar a classe concreta: o domínio `community` não pode +importar as classes de slide de `integration-github` (Domain -> Integration é proibido), e a renderização +por convenção `kind -> Blade` só precisa do contrato `Slide`, não do tipo original. ### Slides e shell diff --git a/app-modules/community/src/CommunityServiceProvider.php b/app-modules/community/src/CommunityServiceProvider.php index c01dfc98b..0a1e994fe 100644 --- a/app-modules/community/src/CommunityServiceProvider.php +++ b/app-modules/community/src/CommunityServiceProvider.php @@ -4,11 +4,25 @@ namespace He4rt\Community; +use He4rt\Community\Retrospective\Actions\CompileSnapshot; +use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; class CommunityServiceProvider extends ServiceProvider { - public function register(): void {} + public function register(): void + { + // Resolve as fontes descobertas por tag no boundary do container; o + // domínio depende só do contrato (dele), nunca das implementações + // concretas em integration-github/activity. + $this->app->bind(CompileSnapshot::class, static function (Application $app): CompileSnapshot { + /** @var iterable $sources */ + $sources = $app->tagged('retrospective.source'); + + return new CompileSnapshot($sources); + }); + } public function boot(): void { diff --git a/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php b/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php new file mode 100644 index 000000000..67475b9e9 --- /dev/null +++ b/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php @@ -0,0 +1,49 @@ + */ + private array $sources; + + /** + * @param iterable $sources + */ + public function __construct(iterable $sources) + { + $this->sources = array_values( + is_array($sources) ? $sources : iterator_to_array($sources, preserve_keys: false), + ); + } + + public function execute(Period $period, SourceFilters $filters): RetrospectiveSnapshot + { + $results = []; + + foreach ($this->sources as $source) { + $result = $source->collect($period, $filters); + + if (!$result->isEmpty()) { + $results[] = $result; + } + } + + return new RetrospectiveSnapshot($results); + } +} diff --git a/app-modules/community/src/Retrospective/Actions/ComposeDeck.php b/app-modules/community/src/Retrospective/Actions/ComposeDeck.php new file mode 100644 index 000000000..3316b6736 --- /dev/null +++ b/app-modules/community/src/Retrospective/Actions/ComposeDeck.php @@ -0,0 +1,60 @@ + + */ + public function execute(RetrospectiveSnapshot $snapshot, DeckConfig $config): array + { + $sources = []; + + foreach ($snapshot->sources as $source) { + if (!$config->showsSource($source->key)) { + continue; + } + + $composed = $this->applySlideVisibility($source, $config); + + if (!$composed->isEmpty()) { + $sources[] = $composed; + } + } + + usort( + $sources, + static fn (SourceResult $a, SourceResult $b): int => $config->position($a->key) <=> $config->position($b->key), + ); + + return $sources; + } + + private function applySlideVisibility(SourceResult $source, DeckConfig $config): SourceResult + { + $slides = array_values(array_filter( + $source->slides, + static fn (Slide $slide): bool => $config->showsSlide($slide->kind()), + )); + + return new SourceResult($source->key, $source->label, $source->headline, $slides); + } +} diff --git a/app-modules/community/src/Retrospective/Actions/PublishRetrospective.php b/app-modules/community/src/Retrospective/Actions/PublishRetrospective.php new file mode 100644 index 000000000..e4330b69a --- /dev/null +++ b/app-modules/community/src/Retrospective/Actions/PublishRetrospective.php @@ -0,0 +1,24 @@ +forceFill(['status' => RetrospectiveStatus::Publishing])->save(); + + dispatch(new CompileRetrospectiveSnapshot($retrospective)); + } +} diff --git a/app-modules/community/src/Retrospective/Casts/AsDeckConfig.php b/app-modules/community/src/Retrospective/Casts/AsDeckConfig.php new file mode 100644 index 000000000..e930e4677 --- /dev/null +++ b/app-modules/community/src/Retrospective/Casts/AsDeckConfig.php @@ -0,0 +1,36 @@ +> + */ +final class AsDeckConfig implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): DeckConfig + { + $payload = json_decode((string) ($value ?? '{}'), associative: true); + + return DeckConfig::makeFromPayload(is_array($payload) ? $payload : []); + } + + /** + * @param DeckConfig|array|null $value + */ + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + $config = match (true) { + $value instanceof DeckConfig => $value, + is_array($value) => DeckConfig::makeFromPayload($value), + default => new DeckConfig(), + }; + + return json_encode($config->toArray(), JSON_THROW_ON_ERROR); + } +} diff --git a/app-modules/community/src/Retrospective/Casts/AsRetrospectiveSnapshot.php b/app-modules/community/src/Retrospective/Casts/AsRetrospectiveSnapshot.php new file mode 100644 index 000000000..88a6223bc --- /dev/null +++ b/app-modules/community/src/Retrospective/Casts/AsRetrospectiveSnapshot.php @@ -0,0 +1,47 @@ +|null> + */ +final class AsRetrospectiveSnapshot implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?RetrospectiveSnapshot + { + if ($value === null || $value === '') { + return null; + } + + $payload = json_decode((string) $value, associative: true); + + return RetrospectiveSnapshot::makeFromPayload(is_array($payload) ? $payload : []); + } + + /** + * @param RetrospectiveSnapshot|array|null $value + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + $snapshot = match (true) { + $value instanceof RetrospectiveSnapshot => $value, + is_array($value) => RetrospectiveSnapshot::makeFromPayload($value), + default => null, + }; + + if (!$snapshot instanceof RetrospectiveSnapshot) { + return null; + } + + return json_encode($snapshot->toArray(), JSON_THROW_ON_ERROR); + } +} diff --git a/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php b/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php index b52b32c11..e182796b6 100644 --- a/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php +++ b/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php @@ -23,5 +23,12 @@ interface RetrospectiveSource { public function key(): string; + /** + * Nome de exibição da fonte (ex.: "GitHub", "Discord"). Estático, resolvível + * sem coletar dado — o CRUD editorial lista/ordena as fontes por (key, label) + * antes de qualquer collect(). + */ + public function label(): string; + public function collect(Period $period, SourceFilters $filters): SourceResult; } diff --git a/app-modules/community/src/Retrospective/DTOs/DeckConfig.php b/app-modules/community/src/Retrospective/DTOs/DeckConfig.php new file mode 100644 index 000000000..b672d07ee --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/DeckConfig.php @@ -0,0 +1,129 @@ + $order keys das fontes na ordem de exibição; fontes fora da lista vão para o fim + * @param list $hiddenSources keys de fonte ocultadas do deck + * @param list $hiddenSlides kinds de slide ocultados (ex.: "github.repos") + * @param array> $exclusions refs escondidos por key de fonte (ex.: ["github" => ["pr:142"]]) + */ + public function __construct( + public array $order = [], + public array $hiddenSources = [], + public array $hiddenSlides = [], + public array $exclusions = [], + ) {} + + /** + * @param array $payload + */ + public static function makeFromPayload(array $payload): self + { + $exclusions = []; + + foreach ((array) ($payload['exclusions'] ?? []) as $key => $refs) { + if (is_string($key)) { + $exclusions[$key] = self::stringList($refs); + } + } + + return new self( + order: self::stringList($payload['order'] ?? []), + hiddenSources: self::stringList($payload['hidden_sources'] ?? []), + hiddenSlides: self::stringList($payload['hidden_slides'] ?? []), + exclusions: $exclusions, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'order' => $this->order, + 'hidden_sources' => $this->hiddenSources, + 'hidden_slides' => $this->hiddenSlides, + 'exclusions' => $this->exclusions, + ]; + } + + public function showsSource(string $key): bool + { + return !in_array($key, $this->hiddenSources, strict: true); + } + + public function showsSlide(string $kind): bool + { + return !in_array($kind, $this->hiddenSlides, strict: true); + } + + /** + * Posição de uma fonte na ordem editorial; fontes fora da lista vão para o fim. + */ + public function position(string $key): int + { + $index = array_search($key, $this->order, strict: true); + + return $index === false ? count($this->order) : $index; + } + + /** + * @return list + */ + public function exclusionsFor(string $key): array + { + return $this->exclusions[$key] ?? []; + } + + /** + * Todos os refs excluídos, achatados, para montar o SourceFilters do collect. + * + * @return list + */ + public function allExclusions(): array + { + $refs = []; + + foreach ($this->exclusions as $sourceRefs) { + foreach ($sourceRefs as $ref) { + $refs[] = $ref; + } + } + + return array_values(array_unique($refs)); + } + + /** + * @return list + */ + private static function stringList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $out = []; + + foreach ($value as $item) { + if (is_string($item)) { + $out[] = $item; + } + } + + return $out; + } +} diff --git a/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php b/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php new file mode 100644 index 000000000..f1c44375f --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php @@ -0,0 +1,133 @@ + $sources + */ + public function __construct( + public array $sources = [], + ) {} + + /** + * @param array $payload + */ + public static function makeFromPayload(array $payload): self + { + $sources = []; + + foreach ((array) ($payload['sources'] ?? []) as $raw) { + if (!is_array($raw)) { + continue; + } + + $sources[] = new SourceResult( + key: is_string($raw['key'] ?? null) ? $raw['key'] : '', + label: is_string($raw['label'] ?? null) ? $raw['label'] : '', + headline: self::headline($raw['headline'] ?? []), + slides: self::slides($raw['slides'] ?? []), + ); + } + + return new self($sources); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'sources' => array_map( + fn (SourceResult $source): array => [ + 'key' => $source->key, + 'label' => $source->label, + 'headline' => [ + 'metrics' => array_map( + fn (Metric $metric): array => ['label' => $metric->label, 'value' => $metric->value], + $source->headline->metrics, + ), + ], + 'slides' => array_map( + fn (Slide $slide): array => ['kind' => $slide->kind(), 'props' => $slide->toArray()], + $source->slides, + ), + ], + $this->sources, + ), + ]; + } + + public function isEmpty(): bool + { + return $this->sources === []; + } + + private static function headline(mixed $raw): HeadlineMetrics + { + $metrics = []; + + $entries = is_array($raw) && is_array($raw['metrics'] ?? null) ? $raw['metrics'] : []; + + foreach ($entries as $metric) { + if (!is_array($metric)) { + continue; + } + + $label = $metric['label'] ?? null; + $value = $metric['value'] ?? null; + + if (is_string($label) && (is_int($value) || is_string($value))) { + $metrics[] = new Metric($label, (int) $value); + } + } + + return new HeadlineMetrics($metrics); + } + + /** + * @return list + */ + private static function slides(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + + $slides = []; + + foreach ($raw as $slide) { + if (!is_array($slide)) { + continue; + } + if (!isset($slide['kind'])) { + continue; + } + $props = []; + + foreach ((array) ($slide['props'] ?? []) as $propKey => $propValue) { + if (is_string($propKey)) { + $props[$propKey] = $propValue; + } + } + + $slides[] = new FrozenSlide((string) $slide['kind'], $props); + } + + return $slides; + } +} diff --git a/app-modules/community/src/Retrospective/Enums/RetrospectiveStatus.php b/app-modules/community/src/Retrospective/Enums/RetrospectiveStatus.php new file mode 100644 index 000000000..51edab04a --- /dev/null +++ b/app-modules/community/src/Retrospective/Enums/RetrospectiveStatus.php @@ -0,0 +1,72 @@ +danger): é um fluxo com cores semânticas próprias. Publishing é + * o estado transitório enquanto o job congela o snapshot. + */ +enum RetrospectiveStatus: string implements HasColor, HasDescription, HasIcon, HasLabel +{ + use StringifyEnum; + + case Draft = 'draft'; + case Publishing = 'publishing'; + case Published = 'published'; + + public function getLabel(): string + { + return match ($this) { + self::Draft => 'Rascunho', + self::Publishing => 'Publicando', + self::Published => 'Publicado', + }; + } + + public function getColor(): string + { + return match ($this) { + self::Draft => 'gray', + self::Publishing => 'warning', + self::Published => 'success', + }; + } + + public function getDescription(): string + { + return match ($this) { + self::Draft => 'Em edição: coleta o dado ao vivo enquanto o operador cura', + self::Publishing => 'Congelando o snapshot em segundo plano', + self::Published => 'Publicada: a página pública lê o snapshot congelado', + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Draft => Heroicon::OutlinedPencilSquare, + self::Publishing => Heroicon::OutlinedArrowPath, + self::Published => Heroicon::OutlinedCheckCircle, + }; + } + + public function isDraft(): bool + { + return $this === self::Draft; + } + + public function isPublished(): bool + { + return $this === self::Published; + } +} diff --git a/app-modules/community/src/Retrospective/Jobs/CompileRetrospectiveSnapshot.php b/app-modules/community/src/Retrospective/Jobs/CompileRetrospectiveSnapshot.php new file mode 100644 index 000000000..55d0384d9 --- /dev/null +++ b/app-modules/community/src/Retrospective/Jobs/CompileRetrospectiveSnapshot.php @@ -0,0 +1,66 @@ +retrospective->getKey(); + } + + public function handle(CompileSnapshot $compile): void + { + $snapshot = $compile->execute( + $this->retrospective->period(), + $this->retrospective->filters(), + ); + + $this->retrospective->forceFill([ + 'snapshot' => $snapshot, + 'status' => RetrospectiveStatus::Published, + 'published_at' => Date::now(), + ])->save(); + } + + public function failed(Throwable $exception): void + { + Log::error('Falha ao congelar o snapshot da retrospectiva', [ + 'retrospective' => $this->retrospective->getKey(), + 'error' => $exception->getMessage(), + ]); + + $this->retrospective->forceFill(['status' => RetrospectiveStatus::Draft])->save(); + } +} diff --git a/app-modules/community/src/Retrospective/Models/Retrospective.php b/app-modules/community/src/Retrospective/Models/Retrospective.php new file mode 100644 index 000000000..589c77e91 --- /dev/null +++ b/app-modules/community/src/Retrospective/Models/Retrospective.php @@ -0,0 +1,108 @@ + */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'title', + 'since', + 'until', + 'status', + 'cover_title', + 'cover_intro', + 'closing_text', + 'hide_bots', + 'deck_config', + 'snapshot', + 'published_at', + ]; + + /** + * Recorte temporal da edição como DTO de domínio (CarbonImmutable), pronto + * para alimentar o collect() das fontes. + */ + public function period(): Period + { + return new Period($this->since->toImmutable(), $this->until->toImmutable()); + } + + /** + * Filtros que mexem no dado, projetados da edição para o collect(): ocultar + * bots (coluna) + exclusions curadas (deck_config). + */ + public function filters(): SourceFilters + { + return new SourceFilters( + hideBots: $this->hide_bots, + exclusions: $this->deck_config->allExclusions(), + ); + } + + public function isPublished(): bool + { + return $this->status->isPublished(); + } + + /** + * @param Builder $query + * @return Builder + */ + protected function scopePublished(Builder $query): Builder + { + return $query->where('status', RetrospectiveStatus::Published->value); + } + + protected function casts(): array + { + return [ + 'since' => 'datetime', + 'until' => 'datetime', + 'published_at' => 'datetime', + 'status' => RetrospectiveStatus::class, + 'hide_bots' => 'boolean', + 'deck_config' => AsDeckConfig::class, + 'snapshot' => AsRetrospectiveSnapshot::class, + ]; + } +} diff --git a/app-modules/community/src/Retrospective/Slides/FrozenSlide.php b/app-modules/community/src/Retrospective/Slides/FrozenSlide.php new file mode 100644 index 000000000..e4f0cad03 --- /dev/null +++ b/app-modules/community/src/Retrospective/Slides/FrozenSlide.php @@ -0,0 +1,39 @@ + Integration é proibido). Ao congelar, só o contrato importa: kind + + * props. O FrozenSlide carrega esse par e satisfaz Slide, então o portal renderiza + * o snapshot pela mesma convenção kind -> Blade, sem conhecer o tipo original. + */ +final readonly class FrozenSlide implements Slide +{ + /** + * @param array $props + */ + public function __construct( + private string $kind, + private array $props, + ) {} + + public function kind(): string + { + return $this->kind; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->props; + } +} diff --git a/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php b/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php new file mode 100644 index 000000000..1b11983af --- /dev/null +++ b/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php @@ -0,0 +1,67 @@ +create(); + + resolve(PublishRetrospective::class)->execute($retrospective); + + expect($retrospective->fresh()->status)->toBe(RetrospectiveStatus::Publishing); + + Bus::assertDispatched(CompileRetrospectiveSnapshot::class); +}); + +it('o job congela o snapshot e promove para publicado', function (): void { + $retrospective = Retrospective::factory()->create([ + 'since' => CarbonImmutable::parse('2026-06-01'), + 'until' => CarbonImmutable::parse('2026-06-30'), + 'status' => RetrospectiveStatus::Publishing, + ]); + + $source = new class implements RetrospectiveSource + { + public function key(): string + { + return 'github'; + } + + public function label(): string + { + return 'GitHub'; + } + + public function collect(Period $period, SourceFilters $filters): SourceResult + { + return new SourceResult('github', 'GitHub', new HeadlineMetrics([new Metric('PRs', 3)]), [ + new FrozenSlide('github.panorama', ['meta' => ['prs' => 3]]), + ]); + } + }; + + new CompileRetrospectiveSnapshot($retrospective)->handle(new CompileSnapshot([$source])); + + $fresh = $retrospective->fresh(); + + expect($fresh->status)->toBe(RetrospectiveStatus::Published) + ->and($fresh->published_at)->not->toBeNull() + ->and($fresh->snapshot->sources)->toHaveCount(1) + ->and($fresh->snapshot->sources[0]->key)->toBe('github'); +}); diff --git a/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php b/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php new file mode 100644 index 000000000..ab41de2d1 --- /dev/null +++ b/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php @@ -0,0 +1,70 @@ + ['pr:1']], + ); + + $snapshot = new RetrospectiveSnapshot([ + new SourceResult('github', 'GitHub', new HeadlineMetrics([new Metric('PRs', 3)]), [ + new FrozenSlide('github.panorama', ['meta' => ['prs' => 3]]), + ]), + ]); + + $retrospective = Retrospective::factory()->create([ + 'deck_config' => $config, + 'snapshot' => $snapshot, + ]); + + $fresh = $retrospective->fresh(); + + expect($fresh->deck_config)->toBeInstanceOf(DeckConfig::class) + ->and($fresh->deck_config->order)->toBe(['github']) + ->and($fresh->deck_config->allExclusions())->toBe(['pr:1']) + ->and($fresh->snapshot)->toBeInstanceOf(RetrospectiveSnapshot::class) + ->and($fresh->snapshot->sources[0]->slides[0])->toBeInstanceOf(FrozenSlide::class) + ->and($fresh->snapshot->sources[0]->slides[0]->kind())->toBe('github.panorama'); +}); + +it('deixa o snapshot nulo enquanto rascunho, com deck_config sempre presente', function (): void { + $retrospective = Retrospective::factory()->create()->fresh(); + + expect($retrospective->snapshot)->toBeNull() + ->and($retrospective->status)->toBe(RetrospectiveStatus::Draft) + ->and($retrospective->deck_config)->toBeInstanceOf(DeckConfig::class); +}); + +it('projeta period e filters a partir das colunas', function (): void { + $retrospective = Retrospective::factory()->create([ + 'since' => CarbonImmutable::parse('2026-06-01 00:00:00'), + 'until' => CarbonImmutable::parse('2026-06-30 23:59:59'), + 'hide_bots' => false, + 'deck_config' => new DeckConfig(exclusions: ['github' => ['pr:9']]), + ]); + + expect($retrospective->period()->since->toDateString())->toBe('2026-06-01') + ->and($retrospective->period()->until->toDateString())->toBe('2026-06-30') + ->and($retrospective->filters()->hideBots)->toBeFalse() + ->and($retrospective->filters()->exclusions)->toBe(['pr:9']); +}); + +it('scopePublished traz só as publicadas', function (): void { + Retrospective::factory()->create(); + $published = Retrospective::factory()->published()->create(); + + expect(Retrospective::query()->published()->pluck('id')->all())->toBe([$published->id]); +}); diff --git a/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php b/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php new file mode 100644 index 000000000..3e858f12c --- /dev/null +++ b/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php @@ -0,0 +1,77 @@ +sourceKey; + } + + public function label(): string + { + return $this->sourceLabel; + } + + public function collect(Period $period, SourceFilters $filters): SourceResult + { + return new SourceResult( + $this->sourceKey, + $this->sourceLabel, + new HeadlineMetrics($this->empty ? [] : [new Metric('Itens', 1)]), + $this->empty ? [] : [new FrozenSlide($this->sourceKey.'.panel', ['n' => 1])], + ); + } + }; +} + +function retroPeriod(): Period +{ + return Period::of(CarbonImmutable::parse('2026-06-01'), CarbonImmutable::parse('2026-06-30')); +} + +it('coleta as fontes e empacota os SourceResult crus', function (): void { + $snapshot = new CompileSnapshot([ + retroFakeSource('github', 'GitHub', empty: false), + retroFakeSource('discord', 'Discord', empty: false), + ])->execute(retroPeriod(), new SourceFilters()); + + expect($snapshot->sources)->toHaveCount(2) + ->and(array_map(fn (SourceResult $source): string => $source->key, $snapshot->sources)) + ->toBe(['github', 'discord']); +}); + +it('descarta fontes sem dado no recorte', function (): void { + $snapshot = new CompileSnapshot([ + retroFakeSource('github', 'GitHub', empty: false), + retroFakeSource('discord', 'Discord', empty: true), + ])->execute(retroPeriod(), new SourceFilters()); + + expect($snapshot->sources)->toHaveCount(1) + ->and($snapshot->sources[0]->key)->toBe('github'); +}); + +it('devolve snapshot vazio quando não há fontes', function (): void { + $snapshot = new CompileSnapshot([])->execute(retroPeriod(), new SourceFilters()); + + expect($snapshot->isEmpty())->toBeTrue(); +}); diff --git a/app-modules/community/tests/Unit/Retrospective/ComposeDeckTest.php b/app-modules/community/tests/Unit/Retrospective/ComposeDeckTest.php new file mode 100644 index 000000000..b669134ef --- /dev/null +++ b/app-modules/community/tests/Unit/Retrospective/ComposeDeckTest.php @@ -0,0 +1,57 @@ + 5]), + ]), + new SourceResult('github', 'GitHub', new HeadlineMetrics([new Metric('PRs', 3)]), [ + new FrozenSlide('github.panorama', ['meta' => []]), + new FrozenSlide('github.repos', ['repos' => []]), + ]), + ]); +} + +it('aplica a ordem editorial do deck_config', function (): void { + $deck = new ComposeDeck()->execute(retroSnapshot(), new DeckConfig(order: ['github', 'discord'])); + + expect(array_map(fn (SourceResult $source): string => $source->key, $deck))->toBe(['github', 'discord']); +}); + +it('oculta um slide por kind mantendo o resto da fonte', function (): void { + $deck = new ComposeDeck()->execute(retroSnapshot(), new DeckConfig( + order: ['github', 'discord'], + hiddenSlides: ['github.repos'], + )); + + $githubKinds = array_map(fn (Slide $slide): string => $slide->kind(), $deck[0]->slides); + + expect($githubKinds)->toBe(['github.panorama']); +}); + +it('remove a fonte inteira quando desligada', function (): void { + $deck = new ComposeDeck()->execute(retroSnapshot(), new DeckConfig(hiddenSources: ['discord'])); + + expect(array_map(fn (SourceResult $source): string => $source->key, $deck))->toBe(['github']); +}); + +it('mantém a fonte com chips mesmo se todos os slides forem ocultados', function (): void { + $deck = new ComposeDeck()->execute(retroSnapshot(), new DeckConfig( + hiddenSlides: ['discord.messages', 'github.panorama', 'github.repos'], + )); + + expect($deck)->toHaveCount(2) + ->and($deck[0]->slides)->toBeEmpty(); +}); diff --git a/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php b/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php new file mode 100644 index 000000000..5aab77ccd --- /dev/null +++ b/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php @@ -0,0 +1,57 @@ + ['pr:1', 'pr:2']], + ); + + $restored = DeckConfig::makeFromPayload($config->toArray()); + + expect($restored)->toEqual($config); +}); + +it('usa defaults sãos com payload vazio', function (): void { + $config = DeckConfig::makeFromPayload([]); + + expect($config->order)->toBeEmpty() + ->and($config->hiddenSources)->toBeEmpty() + ->and($config->exclusions)->toBeEmpty() + ->and($config->showsSource('github'))->toBeTrue() + ->and($config->showsSlide('github.repos'))->toBeTrue(); +}); + +it('descarta tipos inválidos ao reidratar', function (): void { + $config = DeckConfig::makeFromPayload([ + 'order' => ['github', 42, null], + 'hidden_sources' => 'nope', + 'exclusions' => ['github' => ['pr:1', 99], 7 => ['x']], + ]); + + expect($config->order)->toBe(['github']) + ->and($config->hiddenSources)->toBeEmpty() + ->and($config->exclusionsFor('github'))->toBe(['pr:1']); +}); + +it('responde aos helpers de visibilidade, posição e exclusions', function (): void { + $config = new DeckConfig( + order: ['github', 'discord'], + hiddenSources: ['discord'], + hiddenSlides: ['github.repos'], + exclusions: ['github' => ['pr:1'], 'discord' => ['actor:x']], + ); + + expect($config->showsSource('discord'))->toBeFalse() + ->and($config->showsSource('github'))->toBeTrue() + ->and($config->showsSlide('github.repos'))->toBeFalse() + ->and($config->position('discord'))->toBe(1) + ->and($config->position('twitch'))->toBe(2) + ->and($config->exclusionsFor('github'))->toBe(['pr:1']) + ->and($config->allExclusions())->toEqualCanonicalizing(['pr:1', 'actor:x']); +}); diff --git a/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php b/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php new file mode 100644 index 000000000..346058a28 --- /dev/null +++ b/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php @@ -0,0 +1,58 @@ + ['prs' => 3]])], + ), + ]); + + $restored = RetrospectiveSnapshot::makeFromPayload($snapshot->toArray()); + + expect($restored->sources)->toHaveCount(1); + + $source = $restored->sources[0]; + + expect($source->key)->toBe('github') + ->and($source->label)->toBe('GitHub') + ->and($source->headline->metrics[0]->label)->toBe('PRs') + ->and($source->headline->metrics[0]->value)->toBe(3) + ->and($source->slides[0])->toBeInstanceOf(FrozenSlide::class) + ->and($source->slides[0]->kind())->toBe('github.panorama') + ->and($source->slides[0]->toArray())->toBe(['meta' => ['prs' => 3]]); +}); + +it('trata payload vazio como snapshot vazio', function (): void { + expect(RetrospectiveSnapshot::makeFromPayload([])->isEmpty())->toBeTrue() + ->and(RetrospectiveSnapshot::makeFromPayload(['sources' => []])->isEmpty())->toBeTrue(); +}); + +it('ignora fontes e métricas malformadas ao reidratar', function (): void { + $restored = RetrospectiveSnapshot::makeFromPayload([ + 'sources' => [ + 'nope', + [ + 'key' => 'discord', + 'label' => 'Discord', + 'headline' => ['metrics' => [['label' => 'Msgs', 'value' => 5], ['sem' => 'shape']]], + 'slides' => ['descartado', ['kind' => 'discord.messages', 'props' => ['n' => 5]]], + ], + ], + ]); + + expect($restored->sources)->toHaveCount(1) + ->and($restored->sources[0]->headline->metrics)->toHaveCount(1) + ->and($restored->sources[0]->slides)->toHaveCount(1) + ->and($restored->sources[0]->slides[0]->kind())->toBe('discord.messages'); +}); diff --git a/app-modules/community/tests/Unit/Retrospective/RetrospectiveStatusTest.php b/app-modules/community/tests/Unit/Retrospective/RetrospectiveStatusTest.php new file mode 100644 index 000000000..5461c0f6c --- /dev/null +++ b/app-modules/community/tests/Unit/Retrospective/RetrospectiveStatusTest.php @@ -0,0 +1,20 @@ +getLabel())->toBeString()->not->toBeEmpty() + ->and($status->getColor())->toBeString()->not->toBeEmpty() + ->and($status->getDescription())->toBeString()->not->toBeEmpty() + ->and($status->getIcon())->toBeInstanceOf(Heroicon::class); +})->with(RetrospectiveStatus::cases()); + +it('expõe os atalhos de estado', function (): void { + expect(RetrospectiveStatus::Draft->isDraft())->toBeTrue() + ->and(RetrospectiveStatus::Draft->isPublished())->toBeFalse() + ->and(RetrospectiveStatus::Published->isPublished())->toBeTrue() + ->and(RetrospectiveStatus::Publishing->isPublished())->toBeFalse(); +}); diff --git a/app-modules/integration-github/src/Retrospective/GithubSource.php b/app-modules/integration-github/src/Retrospective/GithubSource.php index 8402b7be7..40414028e 100644 --- a/app-modules/integration-github/src/Retrospective/GithubSource.php +++ b/app-modules/integration-github/src/Retrospective/GithubSource.php @@ -34,6 +34,11 @@ public function key(): string return 'github'; } + public function label(): string + { + return 'GitHub'; + } + public function collect(Period $period, SourceFilters $filters): SourceResult { /** @var Collection $contributions */ @@ -47,7 +52,7 @@ public function collect(Period $period, SourceFilters $filters): SourceResult ->values(); if ($contributions->isEmpty()) { - return new SourceResult($this->key(), 'GitHub', new HeadlineMetrics(), []); + return new SourceResult($this->key(), $this->label(), new HeadlineMetrics(), []); } /** @var list> $people */ @@ -81,7 +86,7 @@ public function collect(Period $period, SourceFilters $filters): SourceResult return new SourceResult( key: $this->key(), - label: 'GitHub', + label: $this->label(), headline: $this->headline($meta), slides: $this->slides($meta, $repos, $highlights, $people), ); diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Actions/PublishRetrospectiveAction.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Actions/PublishRetrospectiveAction.php new file mode 100644 index 000000000..0b331f7bc --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Actions/PublishRetrospectiveAction.php @@ -0,0 +1,45 @@ +label('Publicar') + ->icon(Heroicon::OutlinedRocketLaunch) + ->color('success') + ->requiresConfirmation() + ->modalHeading('Publicar retrospectiva') + ->modalDescription('Congela o snapshot atual e publica a edição. Os números ficam fixos até uma nova publicação.') + ->action(function (Retrospective $record): void { + resolve(PublishRetrospective::class)->execute($record); + + Notification::make() + ->success() + ->title('Publicação iniciada') + ->body('O snapshot está sendo congelado em segundo plano.') + ->send(); + }); + } + + public static function getDefaultName(): string + { + return 'publish'; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/CreateRetrospective.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/CreateRetrospective.php new file mode 100644 index 000000000..fd196d9da --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/CreateRetrospective.php @@ -0,0 +1,26 @@ + $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + $data['status'] = RetrospectiveStatus::Draft; + + return DeckConfigForm::collapse($data, existingHiddenSlides: []); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/EditRetrospective.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/EditRetrospective.php new file mode 100644 index 000000000..98dd8bcca --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/EditRetrospective.php @@ -0,0 +1,68 @@ + + */ + protected function getHeaderActions(): array + { + return [ + PublishRetrospectiveAction::make(), + + Action::make('preview') + ->label('Ver preview') + ->icon(Heroicon::OutlinedEye) + ->color('gray') + ->url(fn (Retrospective $record): string => route('community.retrospective.preview', $record)) + ->openUrlInNewTab(), + + DeleteAction::make(), + ]; + } + + /** + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeFill(array $data): array + { + /** @var Retrospective $record */ + $record = $this->getRecord(); + $config = $record->deck_config; + + $data['deck_sources'] = DeckConfigForm::sourceRows($config); + $data['deck_exclusions'] = DeckConfigForm::exclusionRows($config); + + unset($data['deck_config']); + + return $data; + } + + /** + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeSave(array $data): array + { + /** @var Retrospective $record */ + $record = $this->getRecord(); + + return DeckConfigForm::collapse($data, existingHiddenSlides: $record->deck_config->hiddenSlides); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/ListRetrospectives.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/ListRetrospectives.php new file mode 100644 index 000000000..5c5d423ed --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Pages/ListRetrospectives.php @@ -0,0 +1,24 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListRetrospectives::route('/'), + 'create' => CreateRetrospective::route('/create'), + 'edit' => EditRetrospective::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Schemas/RetrospectiveForm.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Schemas/RetrospectiveForm.php new file mode 100644 index 000000000..778b7922a --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Schemas/RetrospectiveForm.php @@ -0,0 +1,117 @@ +components([ + Section::make('Edição') + ->columns(2) + ->schema([ + TextInput::make('title') + ->label('Título') + ->required() + ->maxLength(255) + ->columnSpanFull(), + + DateTimePicker::make('since') + ->label('Início do período') + ->seconds(condition: false) + ->timezone(config('app.display_timezone')) + ->required(), + + DateTimePicker::make('until') + ->label('Fim do período') + ->seconds(condition: false) + ->timezone(config('app.display_timezone')) + ->required(), + ]), + + Section::make('Capa e fecho') + ->description('Só texto editorial; números, avatares e período são computados à parte.') + ->schema([ + TextInput::make('cover_title') + ->label('Título da capa') + ->maxLength(255), + + Textarea::make('cover_intro') + ->label('Introdução da capa') + ->rows(2), + + Textarea::make('closing_text') + ->label('Mensagem de fecho') + ->rows(2), + ]), + + Section::make('Fontes e curadoria') + ->schema([ + Toggle::make('hide_bots') + ->label('Ocultar bots') + ->default(state: true), + + Repeater::make('deck_sources') + ->label('Fontes e ordem') + ->helperText('Arraste para ordenar os blocos. Desligue para ocultar a fonte do deck.') + ->addable(condition: false) + ->deletable(condition: false) + ->reorderable() + ->default(static fn (): array => DeckConfigForm::defaultSourceRows()) + ->schema([ + Hidden::make('key'), + + TextInput::make('label') + ->label('Fonte') + ->disabled() + ->dehydrated(condition: false), + + Toggle::make('enabled') + ->label('Exibir') + ->default(state: true), + ]) + ->columns(2) + ->columnSpanFull(), + + Repeater::make('deck_exclusions') + ->label('Exclusions') + ->helperText('Esconde um item ou pessoa do deck inteiro daquela fonte. Recompila o snapshot ao publicar.') + ->schema([ + Select::make('source') + ->label('Fonte') + ->options(static fn (): array => AvailableSources::map()) + ->required(), + + TextInput::make('ref') + ->label('Ref') + ->placeholder('pr:142, actor:login') + ->required(), + ]) + ->default([]) + ->columns(2) + ->columnSpanFull() + ->addActionLabel('Adicionar exclusion'), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/AvailableSources.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/AvailableSources.php new file mode 100644 index 000000000..a34bc2be4 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/AvailableSources.php @@ -0,0 +1,32 @@ + key => label + */ + public static function map(): array + { + $map = []; + + /** @var iterable $sources */ + $sources = app()->tagged('retrospective.source'); + + foreach ($sources as $source) { + $map[$source->key()] = $source->label(); + } + + return $map; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php new file mode 100644 index 000000000..00d493b59 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php @@ -0,0 +1,145 @@ + + */ + public static function sourceRows(DeckConfig $config): array + { + $available = AvailableSources::map(); + + $ordered = array_values(array_filter( + $config->order, + static fn (string $key): bool => isset($available[$key]), + )); + + $rows = []; + + foreach ($ordered as $key) { + $rows[] = ['key' => $key, 'label' => $available[$key], 'enabled' => $config->showsSource($key)]; + } + + foreach ($available as $key => $label) { + if (!in_array($key, $ordered, strict: true)) { + $rows[] = ['key' => $key, 'label' => $label, 'enabled' => $config->showsSource($key)]; + } + } + + return $rows; + } + + /** + * @return list + */ + public static function defaultSourceRows(): array + { + return self::sourceRows(new DeckConfig()); + } + + /** + * @return list + */ + public static function exclusionRows(DeckConfig $config): array + { + $rows = []; + + foreach ($config->exclusions as $source => $refs) { + foreach ($refs as $ref) { + $rows[] = ['source' => $source, 'ref' => $ref]; + } + } + + return $rows; + } + + /** + * Recolhe os campos planos do form num payload de deck_config. hidden_slides é + * preservado do que já existia (o CRUD da Fase 2 não os edita; o Deck Builder + * da Fase 3 os gerencia). + * + * @param array $data + * @param list $existingHiddenSlides + * @return array + */ + public static function collapse(array $data, array $existingHiddenSlides): array + { + $order = []; + $hiddenSources = []; + + foreach (self::rows($data['deck_sources'] ?? null) as $row) { + $key = is_string($row['key'] ?? null) ? $row['key'] : null; + + if ($key === null) { + continue; + } + + $order[] = $key; + + if (($row['enabled'] ?? false) === false) { + $hiddenSources[] = $key; + } + } + + $exclusions = []; + + foreach (self::rows($data['deck_exclusions'] ?? null) as $row) { + $source = is_string($row['source'] ?? null) ? $row['source'] : null; + $ref = is_string($row['ref'] ?? null) ? mb_trim($row['ref']) : ''; + if ($source === null) { + continue; + } + if ($ref === '') { + continue; + } + + $exclusions[$source][] = $ref; + } + + $data['deck_config'] = [ + 'order' => $order, + 'hidden_sources' => $hiddenSources, + 'hidden_slides' => $existingHiddenSlides, + 'exclusions' => $exclusions, + ]; + + unset($data['deck_sources'], $data['deck_exclusions']); + + return $data; + } + + /** + * @return list> + */ + private static function rows(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $rows = []; + + foreach ($value as $row) { + if (is_array($row)) { + $rows[] = $row; + } + } + + return $rows; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Tables/RetrospectivesTable.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Tables/RetrospectivesTable.php new file mode 100644 index 000000000..f683c78c6 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Tables/RetrospectivesTable.php @@ -0,0 +1,66 @@ +columns([ + TextColumn::make('title') + ->label('Título') + ->searchable() + ->sortable(), + + TextColumn::make('status') + ->label('Status') + ->badge(), + + TextColumn::make('since') + ->label('Início') + ->date('d/m/Y') + ->timezone(config('app.display_timezone')) + ->sortable(), + + TextColumn::make('until') + ->label('Fim') + ->date('d/m/Y') + ->timezone(config('app.display_timezone')) + ->sortable(), + + TextColumn::make('published_at') + ->label('Publicada em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—') + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->label('Status') + ->options(RetrospectiveStatus::class), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index c379aa647..f0fc405cf 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -9,6 +9,7 @@ use Filament\Panel; use He4rt\PanelAdmin\Discord\DiscordCluster; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; +use He4rt\PanelAdmin\Filament\Resources\Retrospectives\RetrospectiveResource; use He4rt\PanelAdmin\Github\GithubCluster; use He4rt\PanelAdmin\Marketing\MarketingCluster; use He4rt\PanelAdmin\Moderation\Livewire\AppealQueue; @@ -42,6 +43,7 @@ public function register(): void ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, + RetrospectiveResource::class, ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', @@ -125,6 +127,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...TwitchCluster::getNavigationItems(), ...GithubCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), + ...RetrospectiveResource::getNavigationItems(), ...DiscordCluster::getNavigationItems(), ]); } diff --git a/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php b/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php new file mode 100644 index 000000000..4ec836e4e --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php @@ -0,0 +1,78 @@ +create(['username' => 'danielhe4rt']); + + config(['he4rt.admins' => 'danielhe4rt']); + + $this->actingAs($user); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('admin cria uma retrospectiva como rascunho com deck_config montado', function (): void { + livewire(CreateRetrospective::class) + ->fillForm([ + 'title' => 'Retro de Junho', + 'since' => '2026-06-01 00:00:00', + 'until' => '2026-06-30 23:59:59', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $retrospective = Retrospective::query()->where('title', 'Retro de Junho')->sole(); + + expect($retrospective->status)->toBe(RetrospectiveStatus::Draft) + ->and($retrospective->snapshot)->toBeNull() + ->and($retrospective->deck_config->order)->toEqualCanonicalizing(['github', 'discord']); +}); + +test('lista as retrospectivas cadastradas', function (): void { + $retrospective = Retrospective::factory()->create(['title' => 'Retro X']); + + livewire(ListRetrospectives::class) + ->loadTable() + ->assertCanSeeTableRecords([$retrospective]); +}); + +test('edita textos e curadoria de uma retrospectiva', function (): void { + $retrospective = Retrospective::factory()->create(); + + livewire(EditRetrospective::class, ['record' => $retrospective->id]) + ->fillForm(['cover_title' => 'Nova capa']) + ->call('save') + ->assertHasNoFormErrors(); + + expect($retrospective->fresh()->cover_title)->toBe('Nova capa'); +}); + +test('publicar pelo painel marca publicando e enfileira o job', function (): void { + Bus::fake(); + + $retrospective = Retrospective::factory()->create(); + + livewire(EditRetrospective::class, ['record' => $retrospective->id]) + ->callAction('publish') + ->assertNotified(); + + expect($retrospective->fresh()->status)->toBe(RetrospectiveStatus::Publishing); + + Bus::assertDispatched( + CompileRetrospectiveSnapshot::class, + fn (CompileRetrospectiveSnapshot $job): bool => $job->retrospective->is($retrospective), + ); +}); diff --git a/app-modules/portal/resources/views/community-retrospective.blade.php b/app-modules/portal/resources/views/community-retrospective.blade.php index 1d0ed5137..57653c918 100644 --- a/app-modules/portal/resources/views/community-retrospective.blade.php +++ b/app-modules/portal/resources/views/community-retrospective.blade.php @@ -2,16 +2,16 @@ {{-- kind -> partial por convenção: "discord.voice_board" => portal::retro.slides.discord.voice-board --}} @php ($slidePartial = fn (string $kind): string => 'portal::retro.slides.'.str_replace('_', '-', $kind)) - @unless ($noData) - - - - @endunless - @if ($noData) @else - + @foreach ($sources as $source) @foreach ($source->slides as $slide) @@ -19,6 +19,11 @@ @endforeach @endforeach - + @endif diff --git a/app-modules/portal/resources/views/components/retro/controls.blade.php b/app-modules/portal/resources/views/components/retro/controls.blade.php deleted file mode 100644 index 8400dbc40..000000000 --- a/app-modules/portal/resources/views/components/retro/controls.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -@props(['since', 'until', 'hideBots' => true]) -@php - $toDate = fn ($value): string => $value instanceof \Carbon\CarbonInterface ? $value->format('Y-m-d') : (string) $value; -@endphp -
-
- -
diff --git a/app-modules/portal/resources/views/components/retro/deck.blade.php b/app-modules/portal/resources/views/components/retro/deck.blade.php index 5b2b4aba5..29f466d6e 100644 --- a/app-modules/portal/resources/views/components/retro/deck.blade.php +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -75,10 +75,12 @@ class="deck-shell"
- + @if (filled($filters ?? null)) + + @endif @endunless
{{ $slot }}
diff --git a/app-modules/portal/resources/views/components/retro/slides/closing.blade.php b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php index 8c7d66d7a..afaa168ac 100644 --- a/app-modules/portal/resources/views/components/retro/slides/closing.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php @@ -1,4 +1,4 @@ -@props(['sources', 'since', 'until']) +@props(['sources', 'since', 'until', 'closingText' => null]) @php $fmt = fn ($d): string => $d instanceof \Carbon\CarbonInterface ? $d->timezone(config('app.display_timezone'))->format('d/m/Y') @@ -9,7 +9,11 @@

Obrigado a quem fez
o coração bater 💜

- Cada PR, cada mensagem, cada call e cada reação manteve a He4rt viva. + @if (filled($closingText)) + {{ $closingText }} + @else + Cada PR, cada mensagem, cada call e cada reação manteve a He4rt viva. + @endif

@foreach ($sources as $source) diff --git a/app-modules/portal/resources/views/components/retro/slides/cover.blade.php b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php index 109bd62a6..628e3b1d5 100644 --- a/app-modules/portal/resources/views/components/retro/slides/cover.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php @@ -1,4 +1,4 @@ -@props(['sources', 'since', 'until']) +@props(['sources', 'since', 'until', 'coverTitle' => null, 'coverIntro' => null]) @php $fmt = fn ($d): string => $d instanceof \Carbon\CarbonInterface ? $d->timezone(config('app.display_timezone'))->format('d/m/Y') @@ -12,12 +12,22 @@
RETROSPECTIVA · {{ $fmt($since) }} — {{ $fmt($until) }}
-

Quem fez a He4rt bater

+

+ @if (filled($coverTitle)) + {{ $coverTitle }} + @else + Quem fez a He4rt bater + @endif +

- Participação da comunidade He4rt em cada frente — gente, código, conversa e presença. + @if (filled($coverIntro)) + {{ $coverIntro }} + @else + Participação da comunidade He4rt em cada frente — gente, código, conversa e presença. + @endif

@foreach ($sources as $source) diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 327d4d089..d28e112af 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -5,63 +5,82 @@ namespace He4rt\Portal\Livewire; use Carbon\CarbonImmutable; -use Carbon\CarbonInterface; -use He4rt\Community\Retrospective\DTOs\Period; -use He4rt\Community\Retrospective\DTOs\SourceFilters; -use He4rt\Portal\Retrospective\RetrospectiveDeck; +use He4rt\Community\Retrospective\Actions\CompileSnapshot; +use He4rt\Community\Retrospective\Actions\ComposeDeck; +use He4rt\Community\Retrospective\DTOs\RetrospectiveSnapshot; +use He4rt\Community\Retrospective\Models\Retrospective; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; -use Livewire\Attributes\Url; use Livewire\Component; +/** + * Página pública da retrospectiva. O visitante só assiste: monta a edição + * publicada mais recente a partir do snapshot congelado, sem filtros e sem tocar + * as fontes (ADR-0002). Em modo preview (rota autenticada), monta uma edição + * específica — coletando ao vivo se ainda for rascunho — pelo mesmo render path, + * então "ver rascunho" bate com o que será publicado. + */ #[Layout(name: 'portal::components.layouts.deck')] #[Title(content: 'Quem fez a He4rt bater')] final class CommunityRetrospectivePage extends Component { - #[Url] - public ?string $since = null; + public ?string $retrospectiveId = null; - #[Url] - public ?string $until = null; + public bool $preview = false; - #[Url] - public bool $hideBots = true; - - public function setPreset(string $preset): void + public function mount(?Retrospective $retrospective = null): void { - $this->since = match ($preset) { - 'mes' => CarbonImmutable::now()->subMonth()->toDateString(), - // "Tudo" = desde antes da fundação da comunidade; cobre todo o histórico - // sem acoplar o portal à menor data de nenhuma fonte específica. - 'tudo' => CarbonImmutable::create(2_015, 1, 1)->toDateString(), - default => CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(), - }; + if ($retrospective instanceof Retrospective) { + // Preview de uma edição específica é só para operadores autenticados: + // rascunhos não podem vazar pela URL pública. + abort_unless(auth()->check(), 403); - $this->until = CarbonImmutable::now()->toDateString(); + $this->retrospectiveId = $retrospective->id; + $this->preview = true; + } } public function render(): View { - $since = $this->since !== null && $this->since !== '' - ? CarbonImmutable::parse($this->since)->startOfDay() - : CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek(); + $retrospective = $this->resolveEdition(); + + if (!$retrospective instanceof Retrospective) { + return view('portal::community-retrospective', [ + 'sources' => [], + 'since' => CarbonImmutable::now(), + 'until' => CarbonImmutable::now(), + 'coverTitle' => null, + 'coverIntro' => null, + 'closingText' => null, + 'stateKey' => 'empty', + ]); + } - $until = $this->until !== null && $this->until !== '' - ? CarbonImmutable::parse($this->until)->endOfDay() - : CarbonImmutable::now(); + $snapshot = $this->preview && !$retrospective->isPublished() + ? resolve(CompileSnapshot::class)->execute($retrospective->period(), $retrospective->filters()) + : ($retrospective->snapshot ?? new RetrospectiveSnapshot()); - $sources = resolve(RetrospectiveDeck::class)->compose( - Period::of($since, $until), - new SourceFilters(hideBots: $this->hideBots), - ); + $sources = resolve(ComposeDeck::class)->execute($snapshot, $retrospective->deck_config); return view('portal::community-retrospective', [ 'sources' => $sources, - 'since' => $since, - 'until' => $until, - 'hideBots' => $this->hideBots, - 'stateKey' => md5((string) json_encode([$this->since, $this->until, $this->hideBots])), + 'since' => $retrospective->since, + 'until' => $retrospective->until, + 'coverTitle' => $retrospective->cover_title, + 'coverIntro' => $retrospective->cover_intro, + 'closingText' => $retrospective->closing_text, + // Sem filtros do visitante: o deck só muda quando a edição muda. + 'stateKey' => $retrospective->id, ]); } + + private function resolveEdition(): ?Retrospective + { + if ($this->retrospectiveId !== null) { + return Retrospective::query()->find($this->retrospectiveId); + } + + return Retrospective::query()->published()->latest('published_at')->first(); + } } diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 267cb2777..7efa068f6 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -4,34 +4,28 @@ namespace He4rt\Portal; -use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Portal\Livewire\CommunityRetrospectivePage; use He4rt\Portal\Livewire\HeroSection; use He4rt\Portal\Livewire\Homepage; use He4rt\Portal\Livewire\SocialLinksPage; -use He4rt\Portal\Retrospective\RetrospectiveDeck; -use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; class PortalServiceProvider extends ServiceProvider { - public function register(): void - { - $this->app->bind(RetrospectiveDeck::class, static function (Application $app): RetrospectiveDeck { - /** @var iterable $sources */ - $sources = $app->tagged('retrospective.source'); - - return new RetrospectiveDeck($sources); - }); - } + public function register(): void {} 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'); + // Preview do operador: monta uma edição específica (rascunho ao vivo ou + // publicada) pelo mesmo render path da pública. O componente aborta com 403 + // para visitantes (não há rota de login web; o guard fica no mount). + Route::get('/comunidade/retrospectiva/{retrospective}/preview', CommunityRetrospectivePage::class) + ->name('community.retrospective.preview'); Livewire::component('hero-section', HeroSection::class); } diff --git a/app-modules/portal/src/Retrospective/RetrospectiveDeck.php b/app-modules/portal/src/Retrospective/RetrospectiveDeck.php deleted file mode 100644 index bdf263b90..000000000 --- a/app-modules/portal/src/Retrospective/RetrospectiveDeck.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ - private const array ORDER = ['github', 'discord']; - - /** @var list */ - private array $sources; - - /** - * @param iterable $sources - */ - public function __construct(iterable $sources) - { - $this->sources = array_values( - is_array($sources) ? $sources : iterator_to_array($sources, preserve_keys: false), - ); - } - - /** - * @return list - */ - public function compose(Period $period, SourceFilters $filters): array - { - $results = []; - - foreach ($this->sources as $source) { - $result = $source->collect($period, $filters); - - if (!$result->isEmpty()) { - $results[] = $result; - } - } - - usort( - $results, - fn (SourceResult $a, SourceResult $b): int => $this->position($a->key) <=> $this->position($b->key), - ); - - return $results; - } - - private function position(string $key): int - { - $index = array_search($key, self::ORDER, strict: true); - - return $index === false ? count(self::ORDER) : $index; - } -} diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index 650aea118..a86abf39f 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -3,114 +3,112 @@ declare(strict_types=1); use Carbon\CarbonImmutable; -use He4rt\Activity\Message\Models\Message; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; +use He4rt\Community\Retrospective\Actions\CompileSnapshot; +use He4rt\Community\Retrospective\DTOs\DeckConfig; +use He4rt\Community\Retrospective\DTOs\Period; +use He4rt\Community\Retrospective\DTOs\SourceFilters; +use He4rt\Community\Retrospective\Models\Retrospective; +use He4rt\Identity\User\Models\User; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; -use He4rt\Portal\Livewire\CommunityRetrospectivePage; -use function Pest\Livewire\livewire; +/** + * Congela o snapshot do período fixo (junho/2026) a partir das fontes vivas e cria + * uma edição publicada com ele. Como usa as fontes reais, os props dos slides + * batem com os partials — exercita todo o caminho congelar -> compor -> renderizar. + */ +function publishRetrospective(array $overrides = []): Retrospective +{ + $since = CarbonImmutable::parse('2026-06-01 00:00:00'); + $until = CarbonImmutable::parse('2026-06-30 23:59:59'); + + $snapshot = resolve(CompileSnapshot::class)->execute( + Period::of($since, $until), + new SourceFilters(), + ); + + return Retrospective::factory()->published($snapshot)->create(array_merge([ + 'since' => $since, + 'until' => $until, + ], $overrides)); +} + +it('mostra o estado vazio quando não há edição publicada', function (): void { + test()->get('/comunidade/retrospectiva') + ->assertOk() + ->assertSee('Métricas') + ->assertSee('reunião') + ->assertSee('discord.gg/he4rt') + ->assertDontSee('Recorte'); +}); -it('mostra os contribuidores do período informado', function (): void { +it('renderiza o deck da edição publicada a partir do snapshot congelado', function (): void { 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], ]); - livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) - ->assertOk() - ->assertSee('maria') - ->assertSee('compbar'); -}); - -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()->create([ - 'actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Issue, - 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', - ]); + publishRetrospective(['cover_title' => 'Retro de Junho', 'closing_text' => 'Valeu, pessoal!']); - livewire(CommunityRetrospectivePage::class) + test()->get('/comunidade/retrospectiva') ->assertOk() - ->assertSee('joao'); + ->assertSee('GitHub') + ->assertSee('maria') + ->assertSee('Retro de Junho') + ->assertSee('Valeu, pessoal!') + ->assertDontSee('Recorte'); }); it('responde na rota pública /comunidade/retrospectiva', function (): void { test()->get('/comunidade/retrospectiva')->assertOk(); }); -it('junta GitHub e Discord no mesmo deck', function (): void { - GithubContribution::factory()->create([ - 'actor_login' => 'maria', 'type' => ContributionType::Pr, - 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], - ]); - - $identity = ExternalIdentity::factory()->create(); - Message::factory()->create(['external_identity_id' => $identity->id, 'sent_at' => '2026-06-02']); +it('não vaza rascunho na página pública', function (): void { + Retrospective::factory()->create(['cover_title' => 'Rascunho Secreto']); - livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) + test()->get('/comunidade/retrospectiva') ->assertOk() - ->assertSee('GitHub') - ->assertSee('Discord') - ->assertSee('O que rolou no chat'); + ->assertDontSee('Rascunho Secreto') + ->assertSee('reunião'); }); -it('inclui e marca contribuidor cujo único PR foi fechado sem merge', function (): void { +it('respeita o on/off de fonte do deck_config na edição publicada', function (): void { 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], + 'actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, + 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], + ]); + + publishRetrospective([ + 'cover_title' => 'Sem GitHub', + 'deck_config' => new DeckConfig(hiddenSources: ['github']), ]); - livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) + // github era a única fonte com dado; oculta => deck fica sem fontes => estado vazio. + test()->get('/comunidade/retrospectiva') ->assertOk() - ->assertSee('rejeitada') - ->assertSee('fechados'); + ->assertDontSee('maria'); }); -it('não renderiza o chrome do portal (sem navbar)', function (): void { +it('preview autenticado monta o rascunho coletado ao vivo', function (): void { GithubContribution::factory()->create([ - 'actor_login' => 'alguem', 'type' => ContributionType::Pr, + 'actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], ]); - test()->get('/comunidade/retrospectiva') - ->assertOk() - ->assertDontSee('Área do Usuário') - ->assertSee('Quem fez a He4rt'); -}); + $retrospective = Retrospective::factory()->create([ + 'since' => CarbonImmutable::parse('2026-06-01 00:00:00'), + 'until' => CarbonImmutable::parse('2026-06-30 23:59:59'), + ]); -it('mostra o convite pra reunião quando não há nenhuma contribuição', function (): void { - // banco zerado: nenhuma fonte com dado → estado vazio com CTA, sem o deck normal - test()->get('/comunidade/retrospectiva') + test()->actingAs(User::factory()->create()) + ->get(route('community.retrospective.preview', $retrospective)) ->assertOk() - ->assertSee('Métricas') - ->assertSee('reunião') - ->assertSee('discord.gg/he4rt') - ->assertDontSee('O panorama') - ->assertDontSee('Filtros'); -}); - -it('mantém o estado do toggle de bots', function (): void { - livewire(CommunityRetrospectivePage::class) - ->set('hideBots', value: false) - ->assertSet('hideBots', value: false); + ->assertSee('maria'); }); -it('preset "tudo" traz o histórico inteiro', function (): void { - $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - - 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()->create([ - 'actor_login' => 'recente', 'actor_id' => 2, 'type' => ContributionType::Issue, - 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', - ]); +it('preview nega acesso a visitante não autenticado', function (): void { + $retrospective = Retrospective::factory()->create(); - livewire(CommunityRetrospectivePage::class) - ->call('setPreset', 'tudo') - ->assertSee('pioneira') - ->assertSee('recente'); + test()->get(route('community.retrospective.preview', $retrospective)) + ->assertForbidden(); }); diff --git a/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php b/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php deleted file mode 100644 index 2c1464b3c..000000000 --- a/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php +++ /dev/null @@ -1,73 +0,0 @@ -sourceKey; - } - - public function collect(Period $period, SourceFilters $filters): SourceResult - { - return new SourceResult( - $this->sourceKey, - $this->label, - new HeadlineMetrics($this->empty ? [] : [new Metric('Itens', 1)]), - ); - } - }; -} - -function composeWith(RetrospectiveSource ...$sources): array -{ - $period = Period::of(CarbonImmutable::parse('2026-06-01'), CarbonImmutable::parse('2026-06-07')); - - return new RetrospectiveDeck($sources)->compose($period, new SourceFilters()); -} - -it('ordena os blocos como github, depois discord, independentemente do registro', function (): void { - $results = composeWith( - fakeSource('discord', 'Discord', empty: false), - fakeSource('github', 'GitHub', empty: false), - ); - - expect(array_map(fn (SourceResult $result): string => $result->key, $results))->toBe(['github', 'discord']); -}); - -it('joga fontes desconhecidas para o fim', function (): void { - $results = composeWith( - fakeSource('twitch', 'Twitch', empty: false), - fakeSource('github', 'GitHub', empty: false), - ); - - expect(array_map(fn (SourceResult $result): string => $result->key, $results))->toBe(['github', 'twitch']); -}); - -it('descarta fontes sem dado no recorte', function (): void { - $results = composeWith( - fakeSource('github', 'GitHub', empty: false), - fakeSource('discord', 'Discord', empty: true), - ); - - expect($results)->toHaveCount(1) - ->and($results[0]->key)->toBe('github'); -}); From 5a2b7489a16a48f266e1c5ec9b4ab07d75eb956d Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 25 Jul 2026 13:34:49 -0300 Subject: [PATCH 2/2] wip --- .../src/Retrospective/DTOs/RetrospectiveSnapshot.php | 2 ++ .../Resources/Retrospectives/Support/DeckConfigForm.php | 1 + .../Feature/Retrospective/RetrospectiveResourceTest.php | 5 +---- package-lock.json | 6 +----- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php b/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php index f1c44375f..bf0005dc3 100644 --- a/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php +++ b/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php @@ -114,9 +114,11 @@ private static function slides(mixed $raw): array if (!is_array($slide)) { continue; } + if (!isset($slide['kind'])) { continue; } + $props = []; foreach ((array) ($slide['props'] ?? []) as $propKey => $propValue) { diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php index 00d493b59..b6496d2aa 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckConfigForm.php @@ -104,6 +104,7 @@ public static function collapse(array $data, array $existingHiddenSlides): array if ($source === null) { continue; } + if ($ref === '') { continue; } diff --git a/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php b/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php index 4ec836e4e..41ed81091 100644 --- a/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Retrospective/RetrospectiveResourceTest.php @@ -71,8 +71,5 @@ expect($retrospective->fresh()->status)->toBe(RetrospectiveStatus::Publishing); - Bus::assertDispatched( - CompileRetrospectiveSnapshot::class, - fn (CompileRetrospectiveSnapshot $job): bool => $job->retrospective->is($retrospective), - ); + Bus::assertDispatched(fn (CompileRetrospectiveSnapshot $job): bool => $job->retrospective->is($retrospective)); }); diff --git a/package-lock.json b/package-lock.json index 72e833fba..81505e687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,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 +44,6 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1802,8 +1800,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -1886,7 +1883,6 @@ "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5",