---
id: SVC-GALLERY
tipo: servico
onda: 2
status: concluido
unidades: [SVC-GALLERY]
fontes:
  - V2/Services/Gallery/
depende_de: [SVC-STORAGE, SVC-MEDIA, SVC-ACTIVITYLOGGER, CORE-BOOT]
consumidores: [MOD-CONEXOES-ASSOCIADOS, MOD-CONEXOES-PARCEIROSINSTITUCIONAIS, MOD-CONEXOES-UNIDADEREGIONAL, MOD-INSTITUCIONAL-ASSOCIESE, MOD-INSTITUCIONAL-SOBRENOS, MOD-INSTITUCIONAL-HISTORIA, MOD-LAYOUTS-BASICPAGE, MOD-LAYOUTS-HOMEPAGE, MOD-PUBLICACOES-NOTICIAS, MOD-PUBLICACOES-AGENDA, MOD-PUBLICACOES-TRABALHECONOSCO, MOD-SUPPORT-STRUCTURALLANDINGEDITOR, MOD-SUPPORT-SUPPORTCONFIGS, SVC-LANDINGSECTIONSEDITOR, SVC-CONTENTRICHEDITOR, SVC-SEO]
criticidade: alta
dono: SvcGallery
---

# Serviço Gallery — galeria de mídias, picker e vínculo a entidades

> Documento de núcleo lógico e integração. O contrato do macro-componente
> `gallery-picker` está em [`ui-contrato.md`](ui-contrato.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Recebe um arquivo (ou um ID existente), grava o original em `gallery/<owner_module>/`, gera variantes e metadados, registra uma linha em `v2_gallery_media` e devolve a mídia; permite listar, editar, rotacionar, arquivar, restaurar, baixar e excluir mídias com escopo por módulo proprietário. |
| Entrada | `POST/PUT/DELETE/GET /cms/v2/gallery*`, componente Blade `v2-service-gallery::components.gallery-picker`, chamadas PHP `GalleryService::upload/seedFromPath`, comandos `v2:populate-images` e `v2:populate-variants`. |
| Saída | Linha em `v2_gallery_media`, arquivo original + variantes no disco `config('filesystems.media_disk')`, JSON de listagem/registro, ZIP de download, trilha de auditoria. |
| Efeitos colaterais | Escrita em storage; auditoria via `ActivityLoggerService` (best-effort: falha só gera `Log::warning`); soft delete e expurgo físico; nenhuma invalidação de cache de página (quem invalida são os módulos). |
| Particularidade central | É o único registro global de mídia reutilizável entre módulos: a visibilidade é `owner_module` + flag `is_shared`, e a autorização de escrita é **exclusivamente** a igualdade de `owner_module` — não há usuário, role ou permissão em nenhuma camada. |
| Como se integra | `GallerySender` (adaptador que instancia `GalleryConfig`) → `GalleryService`; ou `GalleryService` + `GalleryConfig` direto; UI via evento `lotep:open-gallery-picker-<pickerId>` e HTTP JSON. |
| Se removido, quebra | Todas as telas do CMS (o layout global inclui o picker), uploads de imagem/PDF/áudio/planilha de Associados, Unidade Regional, Notícias, Agenda, Trabalhe Conosco, Sobre Nós, Parceiros, BasicPage, SLE, LandingSectionsEditor, ContentRichEditor e SEO. **Risco do legado:** as rotas são anônimas e os subcaminhos são isentos de CSRF (SEC-G1-01); a reimplementação não deve reproduzir isso. |
| Superfície pública | `GalleryService` (9 métodos), `GallerySender` (9 métodos), `GalleryConfig` (4 getters), `GalleryMedia`, `GalleryController` (7 ações), 7 rotas, 11 views, 2 comandos. |

## 1. Efeito

### 1.1 Transformação que o serviço realiza

**Upload**: um `UploadedFile` + `GalleryConfig(owner_module, mimes, max_kb, allow_shared)` é entregue ao
`MediaInputer::processUpload($file, "gallery/{$module}", $disk, $customFilename)`
(`V2/Services/Gallery/Services/GalleryService.php:55-59`). O `MediaInputer` valida o MIME detectado
por magic bytes, grava o original em `gallery/<module>/<nome>.<ext>` e gera variantes (WebP 360/720/1200/1920
para imagem, capa para PDF, derivados para planilha, normalização para áudio). O serviço então insere uma
linha em `v2_gallery_media` e devolve essa linha como array (`GalleryService.php:65-77,92`).

**Seleção**: o picker lista as mídias visíveis, o usuário escolhe uma, edita `alt_text`/`description`
(`PUT /cms/v2/gallery/{id}`) e o callback devolve `(id, '/storage/<file_path>', alt, mimeType, description)`
para o campo do formulário (`Views/components/logic/scripts/gallery-loader.blade.php:54-59`).

**Ciclo de vida**: arquivar = `deleted_at = now()` sem tocar no arquivo; restaurar = `deleted_at = null`;
excluir = apaga arquivo principal e todas as variantes do storage e depois a linha (`GalleryService.php:255-288,297-330,482-523`).

### 1.2 Efeitos sobre dados e sistema

| Efeito | Recurso afetado | Ponto no código | Fonte |
|---|---|---|---|
| INSERT de mídia com `owner_module`, `is_shared`, `file_path`, `mime_type`, `file_size`, `alt_text`, `description`, `variants` (JSON string), `metadata` (JSON string) | `v2_gallery_media` | `GalleryService::upload` | `V2/Services/Gallery/Services/GalleryService.php:65-77` |
| Gravação do original e das variantes | disco de `config('filesystems.media_disk', 'public')` | `MediaInputer::processUpload` chamado em `upload` | `V2/Services/Gallery/Services/GalleryService.php:55-59`; `V2/Services/Media/Services/MediaInputer.php:179-229` |
| Auditoria `created` com `file`, `size`, `mime_type`, `module` | tabela de atividades do `ActivityLogger` | bloco `try` com modelo não persistido | `V2/Services/Gallery/Services/GalleryService.php:79-90` |
| Auditoria `updated`, `archived`, `restored`, `deleted`, `updated` (rotate) | idem | métodos de mutação | `GalleryService.php:277-285,319-327,371-384,454-463,507-516` |
| Soft delete (`deleted_at`) sem remover arquivo | `v2_gallery_media` + storage intacto | `archive` | `GalleryService.php:270-275` |
| Expurgo físico do original e das variantes e depois `DELETE` da linha | storage + `v2_gallery_media` | `delete` | `GalleryService.php:499-520` |
| Sobrescrita do original rotacionado (90° horário) e `updated_at` | storage + linha | `rotate` | `GalleryService.php:430-452` |
| Nenhuma invalidação de cache de página/busca | — | ausência de chamada nos 9 métodos | `GalleryService.php:18-524` |
| Download: ZIP com original + variantes (imagem/planilha) ou redirect para URL temporária/S3 | `storage/app/media_<id>_all.zip` (temporário), resposta HTTP | `download` | `V2/Services/Gallery/Controllers/GalleryController.php:266-290,295-308` |

### 1.3 Contrato observável

`upload()` e `seedFromPath()` retornam `(array) DB::table('v2_gallery_media')->find($id)`
(`GalleryService.php:92`). Consequências literais do formato:

| Campo | Tipo no retorno de `upload` | Tipo no retorno de `listMedia`/`find` |
|---|---|---|
| `id` | int | int |
| `owner_module`, `file_path`, `mime_type`, `alt_text`, `description` | string/null | string/null |
| `is_shared` | bool | bool |
| `variants` | **string JSON** (`json_encode`, `GalleryService.php:73`) | **array** (`json_decode`, `GalleryService.php:207,243`) |
| `metadata` | **string JSON** | **array** |
| `created_at`/`updated_at` | string timestamp | string timestamp |

Operações booleanas: `archive`, `restore`, `update`, `rotate`, `delete` retornam `true`/`false`;
`find` retorna `stdClass|null`; `listMedia` retorna `Illuminate\Support\Collection` de `stdClass`
com `variants`/`metadata` já decodificados.

Erros e mensagens exatas (contrato de HTTP):

| Situação | Resposta | Fonte |
|---|---|---|
| Upload de tipo rejeitado pelo `MediaInputer` | `500 {"success":false,"message":"<mensagem do MediaInputer>"}`; se `type === 'unknown'`, a mensagem é `"Tipo de arquivo não suportado."` | `GalleryService.php:61-63`; `GalleryController.php:105-107` |
| `file` ausente ou fora de `mimes`/`max:10240` | `422` do validador Laravel (mensagens padrão) | `GalleryController.php:88-93` |
| Listagem com exceção | `500` com corpo `[]` | `GalleryController.php:74-77` |
| Update de mídia inexistente ou de outro módulo | `404 {"success":false,"message":"Media not found or unauthorized."}` | `GalleryController.php:146` |
| Rotação inválida (não proprietário, arquivo ausente, MIME não suportado) | `400 {"success":false,"message":"Rotation failed or mime not supported."}` | `GalleryController.php:170` |
| Exclusão inválida | `400 {"success":false,"message":"Deletion failed or unauthorized."}` | `GalleryController.php:194` |
| Archive/restore inválido | `400 {"success":false,"message":"Action failed or unauthorized."}` | `GalleryController.php:224` |
| Download de mídia invisível | `500 {"success":false,"message":"Mídia não encontrada ou acesso negado."}` — o `abort(404)` é capturado pelo `catch (\Exception)` do próprio método | `GalleryController.php:248-250,310-313` |
| Download de arquivo físico ausente | `500 {"success":false,"message":"Arquivo físico não encontrado."}` — mesmo motivo | `GalleryController.php:254-256,310-313` |

**Armadilha do `download`.** Os dois `abort(404, ...)` estão dentro do `try` de `download`; o
`catch (\Exception $e)` do mesmo método (`GalleryController.php:310-313`) captura a
`NotFoundHttpException` (que estende `HttpException` → `\RuntimeException`) e responde
`500 {"success":false,"message":"<mensagem>"}`. Ou seja: **não existe 404 funcional** neste endpoint no
legado; o cliente recebe 500 com a mensagem do abort. A reimplementação deve mover as checagens para
fora do `try` (ou re-lançar `HttpException`) para devolver 404 de verdade.

### 1.4 O que o serviço deliberadamente NÃO faz

- Não autentica nem autoriza usuário: as 7 rotas são registradas só com o grupo `web`
  (`V2/Core/Providers/V2ServiceProvider.php:210-214`), sem `CmsAuthenticate` — diferente de todos os
  `Routes/cms.php` de módulo, que o declaram explicitamente (ex.: `V2/Modules/Conexoes/Associados/Routes/cms.php:3-6`).
- Não verifica CSRF nos subcaminhos: `cms/v2/gallery/*` está em `$except` de `VerifyCsrfToken`
  (`app/Http/Middleware/VerifyCsrfToken.php:15-17`), então `rotate`, `archive` e `destroy` aceitam
  requisição sem `_token`; `store` não é isento, mas o cookie `XSRF-TOKEN` é emitido por qualquer GET.
- Não há guarda de sessão, papel ou `v2_can()` no controller (`GalleryController.php:33-36`).
- Não aplica `GalleryConfig::getAllowedMimeTypes()` nem `getMaxFileSize()` no upload: os getters só são
  usados na listagem (`GalleryService.php:163`) e em nenhum ponto para tamanho (busca vazia por
  `getMaxFileSize` fora do DTO). O limite efetivo é o `max:10240` do controller e a whitelist do `MediaInputer`.
- Não valida `owner_module` contra o usuário/módulo que faz a requisição: o valor vem do request
  (`GalleryController.php:46`) ou do Blade; qualquer cliente pode declarar outro módulo.
- Não remove a mídia quando a entidade que a referencia é excluída; não há contagem de referências.
- Não regenera variantes após rotação nem após troca de metadados.
- Não usa transação entre arquivos e banco em nenhuma operação.
- Não cria thumbnail próprio para PDF/áudio/planilha: a UI usa ícones.
- Não categoriza/tagueia mídia (não existe coluna de tag/categoria).

**Efeito por perfil.** Portal público: nenhum acesso direto ao serviço; consome `file_path`/`variants`
via relations dos módulos. Anônimo: alcança as 7 rotas (sem auth; subcaminhos sem CSRF) e pode listar,
baixar, rotacionar, arquivar, apagar e enviar mídia declarando qualquer `owner_module`
(`Routes/cms.php:5-13`; `VerifyCsrfToken.php:15-17`). CMS autenticado: mesma superfície, sem checagem
de papel. API: sem rotas próprias de Gallery; a API de Notícias ingere via `seedFromPath`. Operação:
comandos Artisan e seeds usam o mesmo pipeline, sem passar por HTTP.

## 2. Abordagem

### 2.1 Estratégia e por quê

Camadas com responsabilidades separadas:

```mermaid
flowchart TD
  UI[gallery-picker / media-input / media-group-input] -->|HTTP JSON| C[GalleryController]
  C -->|getSender| S[GallerySender]
  S -->|new GalleryConfig| D[GalleryConfig]
  S -->|delega| G[GalleryService]
  Modules[Controllers/Seeds de módulos] -->|new GalleryConfig direto| G
  G --> MI[MediaInputer]
  MI --> OPT[ImageOptimizer / PdfVisualOptimizer / AudioOptimizer / SpreadsheetOptimizer]
  MI --> ST[StorageService]
  G --> DB[(v2_gallery_media)]
  G --> AL[ActivityLoggerService]
```

- **Controller thin**: só valida request HTTP e monta resposta JSON; toda regra está no serviço
  (`V2/Services/Gallery/Controllers/GalleryController.php:12-20`).
- **Consumer/DTO**: `GallerySender` é declarado como a única classe que instancia `GalleryConfig`
  (`V2/Services/Gallery/Consumer/GallerySender.php:7-38`). Na prática a regra não é observada: módulos e
  seeds instanciam `GalleryConfig` diretamente (ex.: `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:328`).
  O sender **não carrega identidade nem permissão**: o construtor recebe só `ownerModule`,
  `allowedMimeTypes`, `maxFileSize` e `allowShared` (`Consumer/GallerySender.php:32-38`); portanto ele
  não distingue usuários e não é uma camada de autorização. Na reimplementação, auth/RBAC têm de vir
  do middleware/chamador antes do sender (ver §3.6).
- **Query Builder, não Eloquent**: as leituras/escritas usam `DB::table('v2_gallery_media')`
  (`GalleryService.php:65,141,223,257,342,450,484,518`); o model só serve para `SoftDeletes`,
  `$fillable` e para a auditoria.
- **Escopo por módulo em vez de RBAC**: `owner_module` é a chave de tenancy da mídia; `is_shared`
  amplia leitura, nunca escrita. Motivação inferida: permitir que um módulo reuse imagens de outro sem
  duplicar arquivos, mantendo o "dono" como autoridade de alteração `[I]` (`GalleryService.php:149-160,236-241,266-268,349-351,404-406,493-496`).

### 2.2 Mecânica passo a passo

**FL-GALLERY-01 — upload HTTP**

1. `POST /cms/v2/gallery` cai em `GalleryController@store`; valida `file`
   (`mimes:jpg,jpeg,png,gif,webp,svg,bmp,tiff,tif,pdf,mp3,wav,ogg,m4a,aac,webm,wma,mp4,mpeg,xlsx,xls,csv,ods`,
   `max:10240`), `file_alt`, `description`, `is_shared` (`GalleryController.php:88-93`).
2. `getSender($request)` lê `owner_module` (default `global`), `allow_shared` (default `true`),
   `allowed_mime_types` (CSV ou array) e cria `new GallerySender($ownerModule, $allowedMimeTypes, 10240, $allowShared)`
   (`GalleryController.php:44-55`).
3. `GallerySender::upload` delega ao serviço passando seu `GalleryConfig` (`Consumer/GallerySender.php:71-74`).
4. `GalleryService::upload` lê o disco e o módulo e chama `MediaInputer::processUpload`
   com o prefixo `gallery/{module}` (`GalleryService.php:55-59`).
5. `MediaInputer` valida MIME/extensão, grava com `StorageService::put` (fallback local se o primário
   falhar) e roteia por tipo: `image/*` → `ImageOptimizer::generateVariants`; PDF → metadados + capa;
   áudio → `AudioOptimizer`; planilha → `SpreadsheetOptimizer`; resto → `document`
   (`V2/Services/Media/Services/MediaInputer.php:134-229`).
6. Se `type === 'unknown'` (áudio reprovado na validação do otimizador) o serviço lança
   `"Tipo de arquivo não suportado."` (`GalleryService.php:61-63`).
7. Insere a linha; `alt_text`/`description` caem para o nome original quando vazios
   (`GalleryService.php:71-72`).
8. Tenta auditar `created` com um `GalleryMedia` sem persistência (`forceFill(['id' => $id])`);
   qualquer `Throwable` é engolido com `Log::warning` (`GalleryService.php:79-90`).
9. Retorna a linha recém-lida (`GalleryService.php:92`), serializada como JSON pelo controller
   (`GalleryController.php:104`).

**FL-GALLERY-02 — listagem/seleção**

1. Picker chama `GET /cms/v2/gallery?owner_module=...&allow_shared=...&allowed_mime_types=...&search=...&type=...&archived=...`
   (`Views/components/logic/scripts/gallery-loader.blade.php:6-15`).
2. `getSender` reconstrói o escopo a partir da query (`GalleryController.php:44-55`).
3. `GalleryService::listMedia` filtra `deleted_at` (nulo ou não), visibilidade
   (`owner_module = ? OR is_shared = true` quando `allowShared`, senão só `owner_module`), MIME
   (com wildcard `*` convertido em `%`), tipo rápido (`image`, `pdf`, `audio`, `spreadsheet`) e busca
   `LIKE %termo%` em `alt_text`/`description` (`GalleryService.php:139-210`).
4. Ordena `created_at DESC`, decodifica `variants`/`metadata` e devolve todos os registros
   (`GalleryService.php:205-210`).
5. Clique no card abre o segmento de detalhe; "Confirmar Escolha" faz `PUT /cms/v2/gallery/{id}` com
   `alt_text`/`description` e, no `complete` (sucesso ou falha), chama `selectMedia(...)` que executa o
   callback e fecha o modal (`Views/components/logic/scripts/detail-manager.blade.php:223-246`;
   `gallery-loader.blade.php:54-59`).

**FL-GALLERY-02b — do ID selecionado ao sync no módulo.** O callback entrega um único `id`. Em campos
múltiplos, o módulo acumula IDs em inputs ocultos `<campo>_ids[]` (via `media-group-input`) e
sincroniza no submit: Associados usa `convenios_gallery_ids` → `syncGalleryMediaIds()`
(`InstituicaoCmsController.php:218,251,440-472`), Unidade Regional usa `media_ids` →
`syncGalleryMediaIds()` (`UnidadeRegionalCmsController.php:397-436`), Notícias usa `gallery_ids` →
`$news->gallery()->sync([id => ['sort_order' => $i]])` (`NoticiasCmsController.php:324-332`) e
Associe-se usa `media_ids` → `MembershipPlanController@syncGallery`
(`MembershipPlanController.php:213-239`). Esses métodos **não** chamam `GalleryService`: apenas copiam
`file_path` para a coluna legada e gravam o ID; por isso não há verificação de visibilidade/`deleted_at`.

**FL-GALLERY-03 — arquivar/restaurar/excluir**

- `archive`: exige linha ativa (`whereNull('deleted_at')`) e `owner_module` igual; grava `deleted_at`
  e audita (`GalleryService.php:255-288`).
- `restore`: exige linha arquivada e mesmo dono; limpa `deleted_at` (`GalleryService.php:297-330`).
- `delete`: busca a linha **inclusive arquivada**; exige mesmo dono; apaga original e cada valor de
  `variants` com `StorageService::deleteSafe`; audita; apaga a linha (`GalleryService.php:482-523`).
- `update`: exige mesmo dono; aceita apenas `alt_text`, `description`, `is_shared`; audita
  `old`/`new` (`GalleryService.php:340-387`).

**FL-GALLERY-04 — rotação**

1. `find($id, $config)` com `includeArchived = true` e checagem de visibilidade (`GalleryService.php:398`).
2. Exige dono e existência física (`:404-412`).
3. `ensureLocalPath` baixa do S3 se necessário; marca se o caminho é temporário (`:415-416`).
4. Abre com `imagecreatefromjpeg|png|webp`, aplica `imagerotate($image, -90, 0)` e regrava com
   `imagejpeg|png|webp` (`:421-438`).
5. Sobe de volta com `StorageService::put(file_get_contents(...))`, remove o temporário, atualiza
   `updated_at` e audita (`:444-463`). Não toca em `variants` nem em `metadata`.

**FL-GALLERY-05 — ingestão por seed/deploy**

`seedFromPath($localFilePath, $config, $filename, ...)` valida legibilidade, cria um `UploadedFile`
em modo test e chama `upload(..., customFilename: $filename)`; o caminho final é determinístico
(`gallery/<module>/<filename>.<ext>`) (`GalleryService.php:110-128`; `MediaInputer.php:258-275`).

### 2.3 Trechos de código pivô

```php
// V2/Services/Gallery/Services/GalleryService.php:58-77 (resumido) — upload: processa, rejeita
// 'unknown', insere a linha com variants/metadata JSON e devolve o id incremental.
$uploadResult = $this->mediaInputer->processUpload($file, "gallery/{$module}", $disk, $customFilename);

// ... rejeição de 'unknown' em :61-63 (throw "Tipo de arquivo não suportado.")
$id = DB::table('v2_gallery_media')->insertGetId([
    'owner_module' => $module,
    'is_shared' => (bool) $isShared,
    'file_path' => $uploadResult['path'],
    'mime_type' => $uploadResult['mime_type'],
    'file_size' => $uploadResult['size'],
    'alt_text' => $altText ?: $uploadResult['original_name'],
    'description' => $description ?: $uploadResult['original_name'],
    'variants' => json_encode($uploadResult['variants']),
    'metadata' => json_encode($uploadResult['metadata']),
    'created_at' => now(),
    'updated_at' => now(),
]);
```

**Efeito.** O `id` é inteiro (sequence do PostgreSQL) e `variants`/`metadata` entram como texto JSON.
O `disk` devolvido pelo `MediaInputer` é descartado: a tabela não tem coluna de disco.

```php
// V2/Services/Gallery/Services/GalleryService.php:149-160 — visibilidade: dono OU compartilhada;
// sem `allowShared`, o módulo só enxerga o próprio acervo.
$module = $config->getOwnerModule();

if ($config->getAllowShared()) {
    $query->where(function ($q) use ($module) {
        $q->where('owner_module', $module)
          ->orWhere('is_shared', true);
    });
} else {
    $query->where('owner_module', $module);
}
```

**Efeito.** `allow_shared` é o único mecanismo de compartilhamento e vem do request no caminho HTTP.
Mídia compartilhada aparece em qualquer listagem, mas continua não alterável por quem não é dono.

```php
// V2/Services/Gallery/Services/GalleryService.php:162-175 — o catálogo de MIME da listagem aceita
// curinga e é a ÚNICA aplicação de GalleryConfig::getAllowedMimeTypes().
$allowedMimes = $config->getAllowedMimeTypes();
if (!in_array('*', $allowedMimes) && !in_array('*/*', $allowedMimes)) {
    $query->where(function ($q) use ($allowedMimes) {
        foreach ($allowedMimes as $mime) {
            if (strpos($mime, '*') !== false) {
                $likePattern = str_replace('*', '%', $mime);
                $q->orWhere('mime_type', 'like', $likePattern);
            } else {
                $q->orWhere('mime_type', $mime);
            }
        }
    });
}
```

**Efeito.** Os mimes do DTO restringem o que aparece na grade, não o que pode ser enviado. Uma mídia
enviada por outro módulo com MIME fora da lista fica invisível mesmo sendo `is_shared`.

```php
// V2/Services/Gallery/Services/GalleryService.php:235-246 — leitura por ID com escopo de módulo
// e decodificação dos JSONs (formato diferente do upload).
$isOwner = $media->owner_module === $config->getOwnerModule();
$isAccessibleShared = $media->is_shared && $config->getAllowShared();

if (!$isOwner && !$isAccessibleShared) {
    return null;
}

$media->variants = json_decode($media->variants, true) ?: [];
$media->metadata = json_decode($media->metadata, true) ?: [];
return $media;
```

**Efeito.** `find()` (usado por `rotate`, `download` e por consumidores externos) devolve arrays;
`upload()` devolve strings. Qualquer reimplementação precisa escolher uma forma e migrar os dois
consumidores que hoje fazem `json_decode($result['variants'])`
(`V2/Modules/Layouts/BasicPage/Services/BasicPageAssetManagerService.php:61-63`).

```php
// V2/Services/Gallery/Services/GalleryService.php:429-444 — rotação sobrescreve o original;
// só JPEG/PNG/WebP entram neste caminho e as variantes antigas permanecem no banco/disco.
if ($image) {
    $rotated = imagerotate($image, -90, 0);

    if (strpos($mime, 'jpeg') !== false || strpos($mime, 'jpg') !== false) {
        imagejpeg($rotated, $localPath);
    } elseif (strpos($mime, 'png') !== false) {
        imagepng($rotated, $localPath);
    } elseif (strpos($mime, 'webp') !== false) {
        imagewebp($rotated, $localPath);
    }

    imagedestroy($image);
    imagedestroy($rotated);
```

**Efeito.** A imagem original é reescrita; os WebP responsivos continuam com a orientação antiga e
continuam sendo servidos pelo componente `picture`. `gif`, `svg`, `bmp`, `tiff` e AVIF retornam `false`.

```php
// V2/Services/Gallery/Services/GalleryService.php:492-505 — exclusão física: valida dono, apaga
// arquivos e variantes, sem transação com o DELETE da linha (feito depois da auditoria).
if ($media->owner_module !== $config->getOwnerModule()) {
    Log::warning("[GalleryService] Tentativa não autorizada de deletar mídia ID {$id} por módulo {$config->getOwnerModule()}");
    return false;
}

$this->storageService->deleteSafe($media->file_path);

$variants = json_decode($media->variants, true) ?: [];
foreach ($variants as $width => $variantPath) {
    $this->storageService->deleteSafe($variantPath);
}
```

**Efeito.** Se o `DELETE` da linha falhar depois, a linha fica órfã apontando para arquivos já
removidos. Se dois registros apontarem para o mesmo `file_path` (uploads com nome fixo), excluir um
quebra o outro.

```php
// V2/Services/Gallery/Controllers/GalleryController.php:44-55 — o escopo inteiro nasce de
// parâmetros do request: owner_module, allow_shared e a lista de MIME.
protected function getSender(Request $request)
{
    $ownerModule = $request->input('owner_module', 'global');
    $allowShared = filter_var($request->input('allow_shared', true), FILTER_VALIDATE_BOOLEAN);

    $allowedMimeTypes = $request->input('allowed_mime_types', array());
    if (is_string($allowedMimeTypes)) {
        $allowedMimeTypes = array_filter(explode(',', $allowedMimeTypes));
    }

    return new GallerySender($ownerModule, $allowedMimeTypes, 10240, $allowShared);
}
```

**Efeito.** Não há vínculo entre o usuário autenticado e `owner_module`. Comprometer o parâmetro é
equivalente a assumir o acervo de qualquer módulo (inclusive para `DELETE`), pois as mutações só
comparam esse valor.

```php
// V2/Services/Gallery/Controllers/GalleryController.php:88-104 — validação HTTP e delegação;
// o `mimes` do validador é um catálogo fixo, independente do GalleryConfig e do MediaInputer.
$request->validate([
    'file' => 'required|file|max:10240|mimes:jpg,jpeg,png,gif,webp,svg,bmp,tiff,tif,pdf,mp3,wav,ogg,m4a,aac,webm,wma,mp4,mpeg,xlsx,xls,csv,ods',
    'file_alt' => 'nullable|string|max:255',
    'description' => 'nullable|string|max:255',
    'is_shared' => 'nullable|string',
]);

try {
    $sender = $this->getSender($request);
    $file = $request->file('file');
    $altText = $request->input('file_alt');
    $description = $request->input('description');
    $isShared = filter_var($request->input('is_shared', false), FILTER_VALIDATE_BOOLEAN);

    $media = $sender->upload($file, $altText, $description, $isShared);

    return response()->json($media);
```

**Efeito.** AVIF é aceito pelo `MediaInputer` (normalização de `application/octet-stream`,
`MediaInputer.php:152-155`) mas nunca passa pelo `mimes` do controller. `svg` passa aqui e no
`MediaInputer` (sem variantes). O limite de 10 MB vale para todo upload HTTP, embora o DTO carregue
outro valor (ex.: 4096 em Associados) que é ignorado.

### 2.4 Algoritmos e custos

| Operação | Custo | Observação |
|---|---|---|
| `listMedia` | 1 query sem paginação; `ORDER BY created_at DESC`; `LIKE '%termo%'` não indexado | Devolve o acervo inteiro visível; a grade do picker renderiza todos os cards (`GalleryService.php:205-210`). |
| `find` | PK lookup | Índice parcial `v2_gallery_media_active_idx (id) WHERE deleted_at IS NULL`; com `includeArchived = true` o parcial pode não ser elegível `[I]`. |
| `upload` | leitura integral do arquivo + otimização + N variantes (até 4 WebP) + 1 INSERT | Síncrono no request HTTP; sem fila. |
| `rotate` | download (S3) + decode + encode + upload + 1 UPDATE | 2× tráfego do arquivo; sem regenerar variantes. |
| `download` imagem/planilha | materializa ZIP com original + variantes em `storage/app` e apaga após envio | Custo de disco temporário proporcional ao número de variantes. |
| clique em card no picker | nova `GET` da listagem completa para localizar 1 item | `Views/components/logic/scripts/detail-manager.blade.php:16-28`. |
| exclusão | 1 + N chamadas `exists`+`delete` no storage | `deleteSafe` verifica existência antes (`V2/Services/Storage/StorageService.php:164-170`). |

**Atomicidade.** Nenhuma operação abre transação. `upload` pode deixar arquivo órfão se o INSERT falhar;
`delete` pode deixar a linha órfã se o DELETE falhar após remover os arquivos. As otimizações do
`MediaInputer` engolem exceções: se `ImageOptimizer` falhar depois de definir `type = image`, o upload
segue com `variants = []` (`V2/Services/Media/Services/MediaInputer.php:203-229`).

## 3. Particularidades

### 3.1 O que é exclusivo deste serviço

- **Registro único de mídia com dono declarado**: nenhuma outra tabela compartilha arquivos entre
  módulos por flag; as alternativas (`v2_sle_assets`, `v2_basic_page_assets`) são catálogos locais que
  referenciam `gallery_media_id`.
- **Pipeline de ingestão reutilizado por seed**: `seedFromPath` cria `UploadedFile` em modo test e passa
  pelo mesmo `MediaInputer`, garantindo WebP determinístico (`GalleryService.php:110-128`). É o único
  serviço que expõe essa ponte.
- **Escopo sem RBAC**: a autorização é comparação de string `owner_module`.
- **Picker global**: o layout do CMS inclui uma instância fixa `global-media-picker`
  (`V2/layouts/cms/layouts/app.blade.php:139`), usada por todos os `media-input` do painel.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-GALLERY-01 | Toda mutação exige `owner_module` idêntico ao da linha; `is_shared` não autoriza escrita. | Mídia compartilhada é somente leitura para os demais módulos; a UI tenta salvar e recebe 404. | `GalleryService.php:266-268,308-310,349-351,404-406,493-496` |
| RN-GALLERY-02 | Leitura = próprio acervo **ou** `is_shared` quando `allowShared` é verdadeiro. | Sem `allow_shared`, nem mídia compartilhada aparece. | `GalleryService.php:153-160,236-241` |
| RN-GALLERY-03 | Listagem distingue ativos/arquivados por `deleted_at`; `find` inclui arquivados por padrão. | Download e rotação funcionam em mídia arquivada; o picker ativo não a mostra. | `GalleryService.php:143-147,221-227` |
| RN-GALLERY-04 | `alt_text` e `description` nunca ficam vazios no upload: caem para o nome original do arquivo. | Todo card tem rótulo; acessibilidade mínima garantida. | `GalleryService.php:71-72` |
| RN-GALLERY-05 | O caminho é `gallery/<owner_module>/<nome>.<ext>`; com `customFilename`, o nome é previsível. | Dois uploads com o mesmo nome sobrescrevem o arquivo e criam duas linhas. | `GalleryService.php:59`; `V2/Services/Media/Services/MediaInputer.php:258-275` |
| RN-GALLERY-06 | `upload` devolve `variants`/`metadata` como string JSON; `find`/`listMedia` devolvem arrays. | Consumidores precisam tratar as duas formas. | `GalleryService.php:73-74,207-208,243-244` |
| RN-GALLERY-07 | Arquivar não apaga arquivo; excluir apaga arquivo e variantes e depois a linha. | Arquivamento é reversível mesmo com storage offline; exclusão é irreversível e parcial em falha. | `GalleryService.php:270-275,499-520` |
| RN-GALLERY-08 | Rotação só aceita `jpeg`, `png`, `webp`, gira −90° (horário) e não regenera variantes. | Imagem principal corrigida; `picture` continua servindo WebP antigo. | `GalleryService.php:421-444` |
| RN-GALLERY-09 | O escopo HTTP é 100% derivado do request (`owner_module`, `allow_shared`, `allowed_mime_types`). | Não há vínculo com role/permissão do usuário. | `GalleryController.php:44-55` |
| RN-GALLERY-10 | O DTO não é aplicado no upload (MIME/tamanho); só na listagem (MIME). | Config de módulo não impede upload de outro tipo/tamanho. | `GalleryService.php:53-93,163` |
| RN-GALLERY-11 | Auditoria é best-effort e usa modelo não persistido com apenas `id`. | Falha de auditoria não desfaz a operação. | `GalleryService.php:79-90` |
| RN-GALLERY-12 | Não há coluna de tag/categoria nem de disco. | Não é possível filtrar mídia por assunto nem saber em qual disco o arquivo está. | `V2/Services/Gallery/Migrations/2026_06_28_000000_create_v2_gallery_media_table.php:23-40` |

### 3.3 Casos-limite

| Caso | Comportamento | Fonte |
|---|---|---|
| Arquivo `type === 'unknown'` (áudio reprovado) | Exceção genérica `"Tipo de arquivo não suportado."`; o motivo real do `AudioOptimizer` fica só em `metadata['error']`, que não chega ao cliente porque nada foi inserido. | `GalleryService.php:61-63`; `MediaInputer.php:161-176` |
| Storage primário indisponível | `MediaInputer` cai para `public` e devolve `fallback_local = true`; o Gallery descarta o disco e grava só `file_path`. Leituras futuras usam o disco configurado, que pode não conter o arquivo. | `MediaInputer.php:179-229`; `GalleryService.php:65-77` |
| Nome de arquivo repetido | `StorageService::put` sobrescreve; nova linha com novo `id`; exclusão de uma quebra a outra. | RN-GALLERY-05 |
| Upload concorrente com o mesmo nome | Última escrita vence; não há lock nem sufixo automático. | `MediaInputer.php:258-275` `[I]` |
| `owner_module` com mais de 100 caracteres | Erro de banco (coluna `string(100)`), não validação amigável. | migração `:24` |
| Mídia de outro módulo, `is_shared = true` | Aparece na grade; update/rotate/archive/delete falham com 400/404. | RN-GALLERY-01 |
| Restaurar mídia já ativa | `false` → 400 `Action failed or unauthorized.` | `GalleryService.php:299-305` |
| Excluir mídia já arquivada | Permitido (busca sem filtro de `deleted_at`). | `GalleryService.php:484-486` |
| Rotacionar GIF/SVG/BMP/TIFF/AVIF | `false` → 400 `Rotation failed or mime not supported.` | `GalleryService.php:421-427,472` |
| Excluir mídia cujo arquivo já não existe | `deleteSafe` retorna `true`; linha é removida. | `StorageService.php:164-168` |
| ZIP indisponível (extensão `zip` ausente) | `new \ZipArchive()` gera `Error` (não `Exception`), não capturado pelo `catch (\Exception)` → erro fatal 500 sem JSON. | `GalleryController.php:267-290,310` `[I]` |
| URL pública | Frontend monta `/storage/<file_path>` fixo, ignorando `media_disk`/S3; o backend usa `StorageService`/`temporaryUrl`. | `gallery-loader.blade.php:18`; `GalleryController.php:295-298` |

### 3.4 Armadilhas de reimplementação

1. **Ordem/guard de scripts**: `$GLOBALS['gallery_picker_scripts_rendered']` faz apenas a **primeira**
   inclusão emitir o `<script>` (`Views/components/logic/scripts.blade.php:1-2`). Com dois pickers na
   mesma página, o segundo não recebe listeners. Detalhado no `ui-contrato.md`.
2. **Contrato de retorno misto** (RN-GALLERY-06): não "padronizar" para array sem migrar
   `BasicPageAssetManagerService`, `AssetManagerService` e `ProcessesGalleryImages`, que decodificam
   `variants` como string (`BasicPageAssetManagerService.php:61`, `AssetManagerService.php:61`,
   `V2/Seeds/BasicPages/ProcessesGalleryImages.php:44`).
3. **`owner_module` como string mágica**: os valores em uso precisam ser preservados para não perder
   visibilidade histórica: `associados`, `unidade-regional`, `basicpage`, `sle`, `landing_page`,
   `associese`, `eventos`, `noticias`, `trabalhe-conosco`, `parceiros-institucionais`, `sobrenos`,
   `historia`, `homepage`, `layout_base`, `support_configs`, `seo`, `global`
   (ex.: `Associados/.../edit.blade.php:162`, `plans/edit.blade.php:59-71`, `Agenda/Views/cms/_form.blade.php:25-31`).
4. **Não copiar a ausência de auth**: reimplementar com `CmsAuthenticate` + mapeamento
   usuário→módulo; do contrário o endpoint de exclusão fica aberto.
5. **Rotação não atualiza variantes**: se a UI nova usar `picture` com `variants`, é preciso regenerar
   WebP na rotação.
6. **`is_shared` é avaliado por truthiness** em `find()` (`$media->is_shared && ...`); se o driver
   entregar `'f'` como texto, o valor é verdadeiro em PHP. Garanta cast booleano na leitura `[I]`.
7. **Nome fixo em seed** (`seedFromPath`) é idempotente só no disco, não no banco: cada execução cria
   nova linha. Seeds que rodam duas vezes duplicam registros.
8. **Exclusão sem referência**: apagar uma mídia usada por Notícias/Associados deixa o vínculo
   apontando para ID inexistente; o portal cai no fallback (path legado) ou renderiza vazio.

### 3.5 Dívidas e divergências

| ID | Dívida/divergência | Evidência |
|---|---|---|
| DV-GALLERY-01 | Rotas CMS sem autenticação, ao contrário de todos os módulos. | `V2/Services/Gallery/Routes/cms.php:5-13`; `V2/Core/Providers/V2ServiceProvider.php:210-214`; contraponto `Associados/Routes/cms.php:3-6` |
| DV-GALLERY-02 | `GalleryConfig` não é a única porta de entrada, apesar do comentário "instanciado EXCLUSIVAMENTE aqui". | `Consumer/GallerySender.php:21-28`; usos diretos em `InstituicaoCmsController.php:328`, `UnidadeRegionalCmsController.php:320`, `BasicPageAssetManagerService.php:56` |
| DV-GALLERY-03 | `maxFileSize` morto; `allowedMimeTypes` só na listagem. | `DTOs/GalleryConfig.php:31-61`; `GalleryService.php:163` |
| DV-GALLERY-04 | Placeholder anuncia "padrão regex", backend faz `LIKE`. | `logic/gallery-segment.blade.php:6`; `GalleryService.php:200-201` |
| DV-GALLERY-05 | Docblock cita tipo `document`, filtro não implementa `document`. | `GalleryService.php:135,178-195` |
| DV-GALLERY-06 | README diz que o picker é acionado por `v2-service-media::components.media-input`, mas o layout também inclui uma instância global fixa. | `README.md:22-24`; `V2/layouts/cms/layouts/app.blade.php:139` |
| DV-GALLERY-07 | `is_audio` lido de `$(this)` dentro do callback AJAX (contexto errado), então áudio no detalhe é exibido como PDF. | `logic/scripts/detail-manager.blade.php:30-32,59-65` |
| DV-GALLERY-08 | Sem UI para exclusão permanente e sem confirmação acessível (usa `confirm()` nativo). | `Routes/cms.php:12`; `detail-manager.blade.php:260` |
| DV-GALLERY-09 | Sem seleção múltipla no picker; os campos múltiplos usam `media-group-input` e o endpoint `store` diretamente. | `V2/Services/Media/Views/components/media-group-input.blade.php:36-59` |
| DV-GALLERY-10 | Tipos de `gallery_media_id` divergentes nos consumidores (`uuid` em planos, `string(36)` em Associados/Notícias, `unsignedInteger` em Unidade Regional/SLE/BasicPage). | `2025_07_20_000002_create_v2_membership_plan_gallery_table.php:26`; `2024_01_01_000011_add_gallery_media_id_to_associado_instituicao_gallery.php:17`; `2026_07_30_000001_fix_gallery_media_id_type.php:10-22` |

#### 3.5.1 Catálogo de tags: duplicação e limite

`v2_gallery_media` não tem coluna de tag, categoria ou coleção; a única taxonomia é
`owner_module` + `is_shared` + o filtro de MIME (`migração:23-40`). O CMS convive com **três**
catálogos paralelos de tags: `v2_associado_tags` (Associados,
`2024_01_01_000016_add_category_and_tags_to_associado_instituicoes.php:17-29`), `v2_news_tags`
(Notícias, `2026_07_22_000002_create_v2_news_tags_table.php:17-26`) e `v2_conexao_tags`
(`2026_08_18_000001_create_v2_conexao_tags_table.php:19-24`), com migração de Associados para o
último (`2026_08_18_000002_migrate_associado_tags_to_conexao_tags.php:18-40`).

Efeito no CMS de Associados: a tag é atributo da instituição (`syncTagsFromArray`,
`AssociadoInstituicao.php:231-245`), escolhida no formulário (`instituicoes/_form.blade.php:51-89`),
e **não** da mídia. A galeria da instituição (itens de `v2_associado_instituicao_gallery`) não pode ser
filtrada por tag no picker; a busca só atinge `alt_text`/`description` (`GalleryService.php:198-203`).
Assim, a duplicação de catálogos não é resolvida pelo Gallery: para reimplementar, é preciso decidir se a
mídia terá tags próprias (e migrar os três catálogos) ou se o vínculo tag↔entidade continua sendo só da
entidade.

### 3.6 Superfície de segurança atual (SEC-G1-01)

O legado expõe a Gallery como endpoint anônimo. A cadeia é inteiramente comprovável no código:

| Elo | Evidência |
|---|---|
| Grupo de rotas sem `CmsAuthenticate` | `V2/Services/Gallery/Routes/cms.php:5-13` |
| Provider aplica apenas `web` às rotas CMS de serviço | `V2/Core/Providers/V2ServiceProvider.php:210-214` |
| Controller sem guarda de sessão/papel/`v2_can()` | `V2/Services/Gallery/Controllers/GalleryController.php:33-36` |
| `cms/v2/gallery/*` isento de CSRF | `app/Http/Middleware/VerifyCsrfToken.php:15-17` |
| Escopo de escrita/leitura controlado por parâmetro do cliente | `GalleryController.php:44-55`; `GalleryService.php:236-241,266-268,349-351,493-496` |

Consequências: (a) `GET /cms/v2/gallery` lista metadados de qualquer módulo com
`allow_shared=1` (default) ou assume outro `owner_module`; (b) `GET /{id}/download` baixa o arquivo;
(c) `POST /{id}/rotate`, `POST /{id}/archive` e `DELETE /{id}` são alcançáveis **sem `_token`** por
causa da isenção, bastando declarar o `owner_module` correto; (d) `POST /cms/v2/gallery` exige CSRF,
mas o cookie `XSRF-TOKEN` é emitido em qualquer GET anônimo, então o envio também é viável. O `mimes`
do controller aceita `svg` (`GalleryController.php:88-89`); o `MediaInputer` mantém o SVG original sem
converter (`V2/Services/Media/Services/MediaInputer.php:203-209`), e servir SVG inline do mesmo domínio
é um vetor de XSS armazenado dependente dos headers de resposta `[I]`.

**Para a reimplementação:** aplicar `CmsAuthenticate` + permissão por módulo no grupo
`cms/v2/gallery`, remover a isenção de CSRF e derivar `owner_module` do usuário/módulo no servidor —
nunca do request. Não copiar este estado. O achado completo está em
`_qa/security-g1.md` (SEC-G1-01).

## 4. Integrabilidade

### 4.1 API pública

| Símbolo | Assinatura | Retorno | Exceções | Consumidores |
|---|---|---|---|---|
| `GalleryService::upload` | `(UploadedFile $file, GalleryConfig $config, $altText = null, $description = null, $isShared = false, $customFilename = null)` | `array` (linha, JSON strings) | `\Exception` "Tipo de arquivo não suportado."; exceções do Storage/otimizadores | Controllers de módulos, picker |
| `GalleryService::seedFromPath` | `($localFilePath, GalleryConfig $config, $filename, $altText = null, $description = null, $isShared = false)` | `array` | `\InvalidArgumentException` "Arquivo local não encontrado ou ilegível: {path}"; `\Exception` do upload | Seeds, importador de Notícias, comandos |
| `GalleryService::listMedia` | `(GalleryConfig $config, $searchTerm = null, $type = null, $archived = false)` | `Collection<stdClass>` com arrays | — | `GallerySender`, controller |
| `GalleryService::find` | `($id, GalleryConfig $config, $includeArchived = true)` | `stdClass` ou `null` com arrays | — | `GallerySender`, controller, `download`, `rotate` |
| `GalleryService::archive` | `($id, GalleryConfig $config)` | `bool` | — | Controller, `GallerySender` |
| `GalleryService::restore` | `($id, GalleryConfig $config)` | `bool` | — | Controller, `GallerySender` |
| `GalleryService::update` | `($id, GalleryConfig $config, array $data)` | `bool` | — | Controller, `GallerySender` |
| `GalleryService::rotate` | `($id, GalleryConfig $config)` | `bool` | — (erros de GD viram warning/false) | Controller, `GallerySender` |
| `GalleryService::delete` | `($id, GalleryConfig $config)` | `bool` | — | Controller, `GallerySender` |
| `GallerySender::__construct` | `($ownerModule, array $allowedMimeTypes = [], $maxFileSize = 10240, $allowShared = true)` | — | — | `LandingSectionsEditorController`, `SLE\GallerySender` |
| `GallerySender::getConfig` | `()` | `GalleryConfig` | — | `LandingSectionsEditorController` |
| `GallerySender::{listMedia,upload,find,archive,restore,update,rotate,delete}` | mesmas assinaturas sem `GalleryConfig` | idem ao serviço | idem | LSE, SLE |
| `GalleryConfig::__construct` | `($ownerModule, array $allowedMimeTypes = [], $maxFileSize = 10240, $allowShared = true)` | — | — | módulos e seeds |
| `GalleryConfig::{getOwnerModule,getAllowedMimeTypes,getMaxFileSize,getAllowShared}` | `()` | `string, array, int, bool` | — | serviço/sender |
| `GalleryMedia` | Eloquent, `$table='v2_gallery_media'`, `SoftDeletes`, `$fillable` (9 campos), PK int incremental | — | — | Notícias, Agenda, Trabalhe Conosco, Sobre Nós, Parceiros, AssocieSe, Unidade Regional, Associados |
| Comandos | `v2:populate-images {--only=all,news,eventos,parceiros,associados,regionais,vagas}`, `v2:populate-variants` | exit 0 | — | operação/deploy |

**Contrato de `GalleryConfig`.** O construtor é `($ownerModule, array $allowedMimeTypes = [], $maxFileSize = 10240, $allowShared = true)`
(`DTOs/GalleryConfig.php:31-37`). Se `$allowedMimeTypes` vier vazio, o DTO substitui por
`['image/*','application/pdf']` (por isso o importador de Notícias, que passa `[]`, lista apenas imagens
e PDFs) (`:34`). `$maxFileSize` é armazenado em KB e **nunca lido** pelo serviço; `$allowShared`
default `true` e só afeta leitura. `$ownerModule` é obrigatório e vai direto para o caminho e para o
`INSERT`. O `GallerySender` apenas empacota esses quatro valores + a instância de `GalleryService`
resolvida por `app()` (`Consumer/GallerySender.php:32-38`); não valida, não autoriza e não aplica
limites. Precondição de uso: o chamador já validou MIME/tamanho e já decidiu o módulo.

### 4.2 Componentes Blade e contrato de UI

O macro-componente é `v2-service-gallery::components.gallery-picker`, com parâmetros
`name`, `ownerModule`, `allowedMimeTypes`, `allowShared`. Contrato completo (props, DOM, eventos,
estado, cropper, upload, a11y, init/teardown) em [`ui-contrato.md`](ui-contrato.md).

### 4.3 JS e ciclo de vida

- Dependências duras: jQuery (`$`), Bootstrap 5 (`bootstrap.Modal`), Cropper.js
  (`assets/vendor/cropperjs/cropper.min.js|css`), Bootstrap Icons; `toastr` é opcional
  (`if (typeof toastr !== 'undefined')`, `upload-handler.blade.php:83`).
- Init: um único `$(document).ready` no primeiro picker renderizado
  (`logic/scripts.blade.php:5-30`); o restante do código são declarações `const`/`let` no mesmo escopo
  de IIFE.
- Estado: `pickerId`, `ownerModule`, `allowShared`, `filterMimeTypes`, `csrfToken`, `activeCallback`,
  `cropperInstance`, `fileToUpload`, `viewArchived`, `detailCropperInstance`, `urls.index/store`.
- Teardown: `openGalleryModal` descarta instância Bootstrap residual e limpa backdrops
  (`main-orchestrator.blade.php:12-58`); não há remoção dos handlers `$(document).on('click',
  '.gallery-item-...')` nem destruição do picker ao sair da página (SPA não suportada).
- Sem JS: o modal nunca abre (sem `data-bs-toggle`), upload e seleção inertes; o HTML é só markup.

### 4.4 Ordem de inicialização e registro

1. `V2ServiceProvider::boot` varre `V2/Services/*`; para `Gallery`: carrega migrations de
   `V2/Services/Gallery/Migrations`, views no namespace `v2-service-gallery` e rotas
   (`V2/Core/Providers/V2ServiceProvider.php:168-191`). O namespace vem de `strtolower('Gallery')`.
2. Rotas de serviço são registradas com `web` + namespace `V2\Services\Gallery\Controllers`
   (`V2ServiceProvider.php:210-214`); não recebem `module.active` nem `CmsAuthenticate`.
3. Migrations rodam na ordem global; `v2_gallery_media` precisa existir antes das migrations de
   consumidores que a referenciam por comentário/índice (não há FK física).
4. Views: o picker depende de `v2-service-media::components.media-input` (namespace do serviço Media)
   e, no layout, de `@stack('scripts')` e `@stack('modals')` do `v2-cms::layouts.app`.
5. Comandos registrados explicitamente em `App\Console\Kernel::$commands`
   (`app/Console/Kernel.php:14-22`).
6. `ActivityLoggerService` e `StorageService` são resolvidos pelo container no construtor
   (`GalleryService.php:35-40`); `GallerySender` usa `app(GalleryService::class)`
   (`Consumer/GallerySender.php:32`).

### 4.5 Receitas de uso

**R1 — upload por módulo com DTO (caminho canônico PHP)**

```php
use V2\Services\Gallery\DTOs\GalleryConfig;
use V2\Services\Gallery\Services\GalleryService;

$config = new GalleryConfig('associados', ['image/jpeg', 'image/png', 'image/webp'], 4096, false);
$row = app(GalleryService::class)->upload($request->file('images')[0], $config, 'Fachada', 'Sede');
// $row['id'] (int), $row['file_path'] ('gallery/associados/media_xxx.jpg'), $row['variants'] (string JSON)
```

**R2 — upload via adaptador (LandingSectionsEditor)**

```php
$sender = new \V2\Services\Gallery\Consumer\GallerySender('landing_page');
$media = $sender->upload($request->file('file'), $alt, $description, false);
$mediaId = $media['id'];
```

**R3 — ingerir asset local em seed (mesmo pipeline, nome determinístico)**

```php
$config = new GalleryConfig('sle', ['image/*'], 10240, false);
$media = app(GalleryService::class)->seedFromPath(public_path('assets/logo.png'), $config, 'logo', 'Logo');
// grava gallery/sle/logo.png e cria nova linha a cada execução
```

**R4 — HTTP: listar e escolher**

```http
GET /cms/v2/gallery?owner_module=noticias&allow_shared=1&allowed_mime_types=image/*&archived=0
200 [{"id":95,"file_path":"gallery/noticias/foto.webp","mime_type":"image/webp",
      "alt_text":"Fachada","variants":{"360":"gallery/noticias/variants/foto-360w.webp"}}]
```

**R5 — Blade: incluir o picker e abrir por evento**

```blade
@include('v2-service-gallery::components.gallery-picker', [
    'name' => 'picker_bp_sidebar',
    'ownerModule' => 'basicpage',
    'allowedMimeTypes' => ['image/*'],
    'allowShared' => true,
])
<script>
window.dispatchEvent(new CustomEvent('lotep:open-gallery-picker-picker_bp_sidebar', {
    detail: { callback: (id, url, alt, mime, desc) => document.querySelector('#media-id-x').value = id }
}));
</script>
```

### 4.6 Anti-uso

1. **Criar rota/consumo confiando em `owner_module` do request como autorização.** Qualquer cliente
   anônimo (sem auth e, nos subcaminhos, sem CSRF) pode listar, rotacionar, arquivar e excluir mídia
   alheia declarando outro módulo (`GalleryController.php:44-55`; `GalleryService.php:493-496`;
   `VerifyCsrfToken.php:15-17`). Exige autenticação, permissão e escopo derivado do servidor.
2. **Passar `GalleryConfig` esperando bloqueio de MIME/tamanho no upload.** O DTO não é consultado em
   `upload`; o arquivo entra se o controller e o `MediaInputer` aceitarem
   (`GalleryService.php:53-93,163`). Valide antes de chamar.
3. **Consumir `upload()` e tratar `variants` como array.** O retorno é JSON string; `foreach` sobre
   string gera warning e snippet sem `<source>` (`GalleryService.php:73`; contraponto `find`).
4. **Rotacionar esperando que os WebP acompanhem.** As variantes permanecem com a orientação antiga
   (`GalleryService.php:429-444`). Regenerar via `ImageRedigestService`/`PopulateMissingVariants` não
   cobre rotação; é preciso gerar de novo.
5. **Reutilizar `file_path` como chave estável de entidade.** `Store`/seed com o mesmo nome sobrescreve
   o arquivo; use `gallery_media_id` (`BasicPageAssetManagerService.php:109-125` já deduplica por ID).

### 4.7 Custos e limites

- Upload HTTP: `max:10240` KB no validador; whitelist de extensões fixa
  (`GalleryController.php:89`). Não há quota por módulo.
- Listagem sem paginação: o payload cresce linearmente com o acervo visível; cada card inclui
  `variants` completo.
- ZIP de download em `storage/app` sem limite de tamanho; apagado após envio
  (`GalleryController.php:269-290`).
- Sem throttle próprio nas rotas.
- Auditoria: uma linha por operação; mídias excluídas ainda são referenciadas pelo log (subject id).

**Pontos de extensão.** (a) Novo tipo de arquivo exige alterar o `mimes` do controller
(`GalleryController.php:89`), a whitelist do `MediaInputer` (`MediaInputer.php:52-79`) e, se houver filtro
rápido, `listMedia` + as `<option>` do picker; (b) novo módulo exige apenas um `owner_module` estável e,
se quiser isolamento, `allowShared = false`; (c) novo renderer deve consumir o mapa
`variants = {largura: caminho}` e `alt_text`. Não há registry/plugin: os três pontos são acoplados.

## 5. Rotas e endpoints

| Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|
| GET | `/cms/v2/gallery` | `v2.gallery.index` | `web` (sem auth) | `GalleryController@index` | nenhuma; escopo por `owner_module` do request | Lista mídias ativas/arquivadas com filtros | `V2/Services/Gallery/Routes/cms.php:6`; `GalleryController.php:63-78` |
| POST | `/cms/v2/gallery` | `v2.gallery.store` | `web` (sem auth) | `GalleryController@store` | nenhuma; CSRF exigido, mas token obtenível por GET anônimo | Upload + INSERT + variantes; retorna a linha | `Routes/cms.php:7`; `GalleryController.php:86-109` |
| PUT | `/cms/v2/gallery/{id}` | `v2.gallery.update` | `web` (sem auth) | `GalleryController@update` | alega `owner_module` igual; sem auth | Atualiza `alt_text`, `description`, `is_shared` | `Routes/cms.php:8`; `GalleryController.php:118-151` |
| POST | `/cms/v2/gallery/{id}/rotate` | `v2.gallery.rotate` | `web` (sem auth) | `GalleryController@rotate` | alega `owner_module` igual; subcaminho isento de CSRF | Gira 90° horário e sobrescreve o original | `Routes/cms.php:9`; `GalleryController.php:160-175` |
| GET | `/cms/v2/gallery/{id}/download` | `v2.gallery.download` | `web` (sem auth) | `GalleryController@download` | leitura (dono ou shared com `allow_shared`) | ZIP (imagem/planilha) ou redirect/download do arquivo | `Routes/cms.php:10`; `GalleryController.php:242-314` |
| POST | `/cms/v2/gallery/{id}/archive` | `v2.gallery.archive` | `web` (sem auth) | `GalleryController@archive` | alega `owner_module` igual; subcaminho isento de CSRF | `action=restore` restaura; qualquer outro valor arquiva | `Routes/cms.php:11`; `GalleryController.php:208-229` |
| DELETE | `/cms/v2/gallery/{id}` | `v2.gallery.destroy` | `web` (sem auth) | `GalleryController@destroy` | alega `owner_module` igual; subcaminho isento de CSRF | Apaga arquivos, variantes e linha | `Routes/cms.php:12`; `GalleryController.php:184-199` |

Não há rotas dinâmicas geradas nem parâmetros de path além de `{id}` (inteiro); não há paginação,
ordenação configurável, busca por campo ou filtros além dos listados.

CSRF: `rotate`, `archive` e `destroy` (subcaminhos) são isentos por `cms/v2/gallery/*`
(`VerifyCsrfToken.php:15-17`); `store` e `update` passam pelo `VerifyCsrfToken` do grupo `web`
(`app/Http/Kernel.php:41`), mas o token é obtenível por GET anônimo. Não há autenticação em nenhuma.

Parâmetros: `owner_module` (default `global`), `allow_shared` (default `true`), `allowed_mime_types`
(CSV/array), `search`, `type` (`image|pdf|audio|spreadsheet`), `archived` (`0|1`), `action`
(`archive|restore`). O picker envia `_token` e `X-CSRF-TOKEN` em `store`
(`upload-handler.blade.php:70,78-80`), mas a proteção não vale para os subcaminhos. Não há throttle,
cache nem idempotência: repetir `POST /` cria nova linha.

## 6. Dados e migrations

Tabela própria (detalhe em `_indices/parcial-SVC-GALLERY/tabelas.csv`):

| Coluna | Tipo | Nulo/default | Observação | Fonte |
|---|---|---|---|---|
| `id` | increments (serial/bigserial int) | PK | IDs inteiros, não UUID | `migração:24` |
| `owner_module` | string(100) | não | escopo de leitura/escrita | `:25` |
| `is_shared` | boolean | `false` | amplia leitura | `:26` |
| `file_path` | string | não | caminho relativo ao disco de mídia | `:28` |
| `mime_type` | string(100) | não | MIME detectado/normalizado pelo `MediaInputer` | `:29` |
| `file_size` | unsignedBigInteger | não | bytes originais | `:30` |
| `alt_text` | string | sim | fallback = nome original | `:31` |
| `description` | text | sim | fallback = nome original | `:32` |
| `variants` | json | sim | mapa `{largura: caminho}` | `:35` |
| `metadata` | jsonb | sim | dados do otimizador (páginas PDF, áudio etc.) | `:36` |
| `created_at`/`updated_at` | timestamps | sim | `updated_at` funciona como cache-buster na UI | `:38` |
| `deleted_at` | softDeletes | sim | arquivamento | `:39` |

Índices: `v2_gallery_media_active_idx (id) WHERE deleted_at IS NULL` e
`v2_gallery_media_owner_idx (owner_module) WHERE deleted_at IS NULL` (`:43-44`). Sem FK, sem unique.
`down()` **não** dropa: renomeia para `backup_v2_gallery_media_<Y_m_d_His>` e registra `Log::alert`
(`:59-71`).

Model `GalleryMedia`: `SoftDeletes`, `$table`, `$incrementing = true`, `$keyType = 'int'`,
`$fillable` com os 9 campos (`Models/GalleryMedia.php:8-28`). Sem `$casts` para `variants`/`metadata`,
por isso os consumidores decodificam manualmente.

Pivôs e tabelas consumidoras relevantes (schema na íntegra em `tabelas.csv`):

| Tabela | Vínculo | Tipo da coluna de mídia | Quem escreve |
|---|---|---|---|
| `v2_associado_instituicao_gallery` | `gallery_media_id` + `image` (path duplicado) | `string(36)` | `InstituicaoCmsController@uploadGallery/syncGallery/syncGalleryMediaIds` |
| `v2_unidade_regional_media` | `gallery_media_id` + `image` | `unsignedInteger` (corrigido de uuid) | `UnidadeRegionalCmsController` |
| `v2_news_gallery_media` | `gallery_media_id` + `sort_order` | `unsignedBigInteger` | `NoticiasCmsController` (`sync`), `NoticiasImportService` |
| `v2_membership_plan_gallery` | `gallery_media_id` + `image` | `uuid` (não corrigido) | `MembershipPlanController@syncGallery` |
| `v2_basic_page_assets` | `gallery_media_id` | `integer unsigned` | `BasicPageAssetManagerService` |
| `v2_sle_assets` | `gallery_media_id` | `unsignedInteger` | `AssetManagerService` |
| `v2_news.cover_image_id`, `v2_eventos.image_id`, `v2_job_openings.cover_image_id`/`attachment_pdf_id`, `v2_board_members.photo_id`, `v2_parceiros.logo_id` | coluna direta | `string(36)` | controllers dos módulos |

## 7. Configuração e permissões

| Chave | Default | Efeito | Consumidores | Fonte |
|---|---|---|---|---|
| `filesystems.media_disk` | `env('MEDIA_DISK', env('FILESYSTEM_DRIVER', 'public'))` | disco lógico usado pelo `StorageService`/`MediaInputer`; não é gravado na linha | `GalleryService::upload/rotate/delete` | `config/filesystems.php:24`; `GalleryService.php:55` |
| `cache.prefix` | `laravel` | prefixo das chaves `page:*` limpas pelo comando | `PopulateMissingImages::clearPageCache` | `Console/Commands/PopulateMissingImages.php:322-331` |

Serviços não implementam `ModuleProviderInterface`: não têm navegação, `position`, flag
`module.active` nem `getConfig()`. O registro é a varredura automática de `V2/Services`
(`V2/Core/Providers/V2ServiceProvider.php:168-191`); não existe feature toggle que desative a Gallery.

Permissões: **nenhuma** no serviço. As rotas não têm `CmsAuthenticate`, `V2PermissionMiddleware` nem
`module.active`; a única barreira é `owner_module`, e os subcaminhos ainda são isentos de CSRF
(`VerifyCsrfToken.php:15-17`). Ver §3.6 (SEC-G1-01) (`Routes/cms.php:5-13`;
`V2ServiceProvider.php:210-214`). Não há chave de config para tamanho/tipos: os defaults vivem no DTO
(`GalleryConfig.php:34`) e no validador do controller (`GalleryController.php:89`).

## 8. Observabilidade e erros

| Evento | Log/auditoria | Nível | Fonte |
|---|---|---|---|
| Falha de auditoria no upload | `[GalleryService] Falha ao registrar auditoria de upload: {msg}` | warning | `GalleryService.php:89` |
| Falha de auditoria no archive/restore/update/rotate/delete | mensagens análogas por operação | warning | `:284,326,383,462,515` |
| Tentativa de excluir mídia de outro módulo | `"[GalleryService] Tentativa não autorizada de deletar mídia ID {id} por módulo {module}"` | warning | `:494` |
| Erro ao listar | `[GalleryController] Erro ao listar mídias: {msg}` + 500 `[]` | error | `GalleryController.php:75-76` |
| Erro no upload | `[GalleryController] Upload falhou: {msg}` + 500 JSON | error | `:106-107` |
| Erro no update/rotate/delete/archive/download | `[GalleryController] ... falhou: {msg}` | error | `:148,172,196,226,311` |
| Fallback de download para disco local | `[GalleryController] Fallback para download local: {msg}` | info | `:300` |
| Migração | `ERRO CRÍTICO NA MIGRAÇÃO v2_gallery_media: {msg}` / `ERRO AO REVERTER...` | error | `migração:48,74` |
| Comando de imagens | `[PopulateMissingImages] Falha ao ingerir ...` / `Falha ao limpar cache ...` | warning | `PopulateMissingImages.php:273,330` |

Auditoria (`ActivityLoggerService::log`) grava propriedades `v2_audit`, `ip_address`, `user_agent`,
`causedBy(Sentinel::getUser())` quando há usuário e o `description` (`created|updated|archived|restored|deleted`)
(`V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`). Como o modelo de auditoria é um
`GalleryMedia` não salvo com apenas `id`, o log fica ligado à classe + ID.

## 9. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| **MOD-CONEXOES-ASSOCIADOS** | `GalleryService::upload`; `GalleryConfig('associados', [jpeg,png,webp], 4096, false)`; `GalleryMedia::find`; tabela `v2_associado_instituicao_gallery` (`gallery_media_id` varchar + `image` path) | Upload em lote por `uploadGallery` (array `images`), sync por `convenios_gallery_ids`/`media_ids`, logo por `gallery_logo_id`; portal lê `galleryMedia->file_path`/`variants` com fallback para `image` | Alto: o DTO restritivo não bloqueia upload (RN-GALLERY-10); `syncGalleryMediaIds` apaga itens com `gallery_media_id` não nulo fora da lista e **não** apaga os arquivos no Gallery; IDs são strings em coluna varchar; o formulário usa `media-input`/`media-group-input`, não o picker para múltiplas |
| **MOD-CONEXOES-UNIDADEREGIONAL** | `GalleryService::upload`; `GalleryConfig('unidade-regional', [jpeg,png,webp], 4096, false)`; `seedFromPath` na API de notícias (`GalleryConfig($module, $allowedMimes, 10240, false)`); `v2_unidade_regional_media` | Upload em lote, sync por IDs, capa/logo em `metadata['cover_image_id']`/`logo_id`; API importa imagem remota e usa `(int) id` | Alto: `image` duplica o path (pode divergir do Gallery); coluna corrigida de uuid→integer; sync não remove arquivos; portal depende de `galleryMedia` |
| **MOD-INSTITUCIONAL-ASSOCIESE** | `MembershipPlanController@syncGallery` + `GalleryMedia::find`; `v2_membership_plan_gallery` | Sincroniza `media_ids`; `image` recebe `file_path` | Alto: coluna `gallery_media_id` é `uuid` e o PK do Gallery é int — a gravação tende a falhar no PostgreSQL; a relation `galleryMedia` não resolve; o formulário usa `media-group-input` com `ownerModule = associese` |
| **MOD-LAYOUTS-BASICPAGE** | `GalleryService::upload`; `GalleryConfig('basicpage', ['image/*'], 10240, false)`; `createFromGallery`; `v2_basic_page_assets.gallery_media_id` | Sidebar de assets inclui `gallery-picker` `picker_bp_sidebar` e cria asset a partir do ID; `uploadImage` decodifica `variants` string; snippet `picture` | Alto: `createFromGallery` lê o registro direto no banco (ignora escopo/`deleted_at`); deduplica por `gallery_media_id`; picker próprio depende do guard global de scripts |
| **MOD-SUPPORT-STRUCTURALLANDINGEDITOR** | `AssetManagerService` (config `sle`, `image/*`); `SLE\GallerySender` (subclasse com `structural_landing_editor`, `image/*`, shared=true); `v2_sle_assets`; abas consultam `v2_gallery_media` por `file_path` | Upload/seed de imagens, sidebar com picker `picker_sle_sidebar` (`ownerModule = sle`), logos/tema resolvem ID por path | Alto: duas identidades de módulo (`sle` no picker/upload, `structural_landing_editor` no sender) quebram visibilidade cruzada; snippets usam `v2_storage_url` |
| **SVC-LANDINGSECTIONSEDITOR** | `GallerySender('landing_page')`; validações `exists:v2_gallery_media,id`; `image-picker` consulta por ID; renderer consulta a tabela | `storeMedia`/`updateMedia`/`rotateMedia`; seções guardam `image_id`/`background_image_id` no JSON | Alto: IDs inválidos/excluídos quebram `exists`; rotação não atualiza variantes usadas no render; picker `lse_global_media_picker` sofre o guard de scripts |
| **SVC-CONTENTRICHEDITOR** | Picker global via evento; callback converte `/storage/<path>` em `path` relativo | Insere `insertContentRichMedia` image/pdf | Médio: depende do picker global ter JS ativo; PDF é decidido por extensão da URL |
| **SVC-SEO** | Consulta `v2_gallery_media` por `file_path` quando só há URL | Preenche `image_id` e `media-input` (`ownerModule = seo`) | Médio: path legado sem linha → `image_id` vazio; depende do picker global |
| **MOD-PUBLICACOES-NOTICIAS** | `News::coverImage()`/`gallery()`; `cover_image_id` string(36); pivot `v2_news_gallery_media`; `NoticiasImportService::seedFromPath`; `GalleryMedia::find` no CMS | Capa via `media-input`; galeria via `media-group-input` (`gallery_ids` + `sync` com `sort_order`); importador ingere imagens do corpo e sincroniza | Alto: coluna string vs PK int; importador usa `owner_module = unidade-regional` com `allowShared=true`; exclusão de mídia quebra capa/galeria sem fallback |
| **MOD-PUBLICACOES-AGENDA** | `v2_eventos.image_id` string(36); relation `image()`; `getImageUrl`/`getImageVariants` | `media-input` com `ownerModule = eventos` | Alto: tipo da coluna divergente; portal usa `v2_storage_url` e variantes do Gallery |
| **MOD-PUBLICACOES-TRABALHECONOSCO** | `cover_image_id` e `attachment_pdf_id` string(36) | Dois `media-input` (`ownerModule = trabalhe-conosco`), imagem e PDF | Alto: validação `string, max:36`; PDF depende de `variants` de capa; módulo pode ser desativado por flag |
| **MOD-INSTITUCIONAL-SOBRENOS** | `BoardMember.photo_id` string(36); relation `photoMedia`; o seeder de seções padrão usa o trait `ProcessesGalleryImages` (`GalleryService::seedFromPath`) | `media-input` (`ownerModule = sobrenos`); seeder de seções padrão | Médio-alto: coluna string; exclusão deixa foto órfã |
| **MOD-CONEXOES-PARCEIROSINSTITUCIONAIS** | `Parceiro.logo_id` string(36); `galleryLogo` | `media-input` (`ownerModule = parceiros-institucionais`) | Médio: leitura por relation; sem sync de galeria |
| **MOD-INSTITUCIONAL-HISTORIA** | `image_id` string(36) em milestones | `media-input` (`ownerModule = historia`); portal aceita caminho legado | Médio: comentário do portal prevê caminho legado quando não há Gallery |
| **MOD-LAYOUTS-HOMEPAGE** | `media-input` (`ownerModule = homepage`); `image_id` dentro de `parameters` JSON; schemas validam `string, max:255` (não `exists`) | Imagens de widgets LSE | Médio: ID inválido só falha na renderização, não na validação |
| **MOD-SUPPORT-SUPPORTCONFIGS** | `v2_gallery_media` por `find(cms_brand_logo)` | Converte ID em `file_path` ao salvar tema | Baixo-médio: ID inexistente mantém valor cru |
| **SVC-MEDIA (ImageRedigestService)** | lê `file_path`, `alt_text`, `variants` por ID/path | Redigest de snippets | Médio: assume contrato de `variants` mapa largura→path |
| **Seeds/deploy** | `seedFromPath` (`sle`, `basicpage`, módulos) | Populam galeria com nomes fixos | Alto: duplicação de linhas a cada execução (RN-GALLERY-05) |

**Risco transversal.** Qualquer mudança de PK int→uuid, de `variants` string→array ou de nome de
`owner_module` quebra simultaneamente as 18 linhas do mapa acima (17 consumidores de código + `Seeds/deploy`). O caminho de menor risco é manter o schema e
o formato, adicionando apenas autenticação e mapeamento servidor de `owner_module`.

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-GALLERY-01 | A exposição anônima é confirmada no código (SEC-G1-01); não foi possível determinar se um proxy/WAF externo a bloqueia em produção. | Reimplementação deve decidir o mapeamento usuário→módulo e adicionar auth/CSRF; sem isso, o serviço é aberto. | `V2/Services/Gallery/Routes/cms.php`; `VerifyCsrfToken.php:15-17`; configuração de servidor/proxy fora do repositório |
| LG-GALLERY-02 | Não há contagem de referências nem tabela de vínculos central; não é possível saber quais entidades usam uma mídia. | Exclusão pode deixar referências quebradas; limpeza exige varredura manual das 12+ colunas/pivôs. | busca por `gallery_media_id`/`cover_image_id`/`image_id` em `V2/Modules` |
| LG-GALLERY-03 | O comportamento real do `ImageOptimizer` (larguras, qualidade, nomes) não foi detalhado aqui por pertencer a `SVC-MEDIA`. | Variantes e paths precisam ser confirmados no serviço Media antes de reimplementar seeds. | `V2/Services/Media/Services/Midia/ImageOptimizer.php` |
| LG-GALLERY-04 | Não há testes automatizados da unidade no repositório; o comportamento de borda (ZIP, concorrência) é inferido. | Casos de falha precisam de validação manual. | ausência de `tests/` para Gallery |
| LG-GALLERY-05 | Não foi possível confirmar se `v2_membership_plan_gallery.gallery_media_id` (uuid) já falhou em produção ou se os planos não usam a galeria. | Consumidor pode estar inoperante; verificar dados. | `MembershipPlanController.php:213-239`; migração `2025_07_20_000002` |

**Seeds e dados iniciais.** A unidade não possui `Seeder` próprio (L1–L3 não se aplicam). Os dois
comandos são operacionais e idempotentes por natureza: `v2:populate-images` só preenche registros sem
imagem e `v2:populate-variants` só linhas com `variants` vazio
(`PopulateMissingImages.php:77-130`; `PopulateMissingVariants.php:33-42`). O efeito em dados é indireto
(cria mídia e atualiza colunas dos módulos), por isso os seeds de módulos aparecem no mapa de
consumidores, não aqui.

## 11. Checklist de paridade

- [ ] `PC-GALLERY-01` — `POST /cms/v2/gallery` com JPEG válido cria 1 linha em `v2_gallery_media`, grava o original em `gallery/<owner_module>/`, devolve `id` int e `variants` string JSON; repetir com outro arquivo cria outra linha. Fonte: `GalleryService.php:53-92`.
- [ ] `PC-GALLERY-02` — Upload de `.exe`/MIME não suportado retorna 422 (extensão fora de `mimes`) ou 500 com `"Tipo de arquivo não suportado."`, sem linha criada. Fonte: `GalleryController.php:88-93`; `GalleryService.php:61-63`.
- [ ] `PC-GALLERY-03` — Listagem com `owner_module=associados&allow_shared=0` não retorna mídia de outro módulo nem `is_shared=true`. Fonte: `GalleryService.php:153-160`.
- [ ] `PC-GALLERY-04` — `PUT /cms/v2/gallery/{id}` de mídia de outro módulo retorna 404 `"Media not found or unauthorized."` e não altera a linha. Fonte: `GalleryController.php:140-146`.
- [ ] `PC-GALLERY-05` — `POST /{id}/archive` grava `deleted_at` e mantém o arquivo; `action=restore` limpa `deleted_at`; a listagem `archived=1` mostra a mídia arquivada. Fonte: `GalleryService.php:255-330`.
- [ ] `PC-GALLERY-06` — `DELETE /{id}` remove original e todas as variantes do storage e apaga a linha; mídia de outro módulo retorna 400 e permanece. Fonte: `GalleryService.php:482-523`.
- [ ] `PC-GALLERY-07` — `POST /{id}/rotate` em JPEG gira 90° horário e atualiza `updated_at`; em GIF/PDF retorna 400 com `"Rotation failed or mime not supported."`. Fonte: `GalleryService.php:396-473`; `GalleryController.php:170`.
- [ ] `PC-GALLERY-08` — `GET /{id}/download` de imagem devolve ZIP com original + variantes; de PDF/áudio redireciona para URL temporária ou baixa o local. Fonte: `GalleryController.php:266-308`.
- [ ] `PC-GALLERY-09` — `GalleryService::find` devolve `variants`/`metadata` como arrays e `upload` como strings JSON. Fonte: `GalleryService.php:73-74,243-244`.
- [ ] `PC-GALLERY-10` — No legado, as 7 rotas respondem sem sessão Sentinel e `rotate`/`archive`/`destroy` sem `_token` (SEC-G1-01). A reimplementação **não** deve reproduzir: `401` sem sessão, `403` sem permissão de módulo, `419` sem CSRF e `owner_module` derivado do servidor. Fonte: `Routes/cms.php:5-13`; `V2ServiceProvider.php:210-214`; `VerifyCsrfToken.php:15-17`; `GalleryController.php:33-36`.
- [ ] `PC-GALLERY-15` — `POST /cms/v2/gallery` aceita `svg`, grava o original sem variantes e o serve por `/storage/...`; a reimplementação deve sanitizar SVG ou forçar download com `Content-Disposition`/CSP. Fonte: `GalleryController.php:88-89`; `MediaInputer.php:203-209`; `gallery-loader.blade.php:18`.
- [ ] `PC-GALLERY-11` — `v2:populate-variants` só atualiza linhas de imagem (exceto SVG) com `variants` nulo/`null`/`{}`/`[]` e encontra `variants/<base>-{360,720,1200,1920}w.webp` ou `<base>.webp`. Fonte: `PopulateMissingVariants.php:33-93`.
- [ ] `PC-GALLERY-12` — `v2:populate-images --only=news` cria mídia para notícias com `cover_image_id` nulo e a vincula; segunda execução não altera registros já preenchidos. Fonte: `PopulateMissingImages.php:48-130`.
- [ ] `PC-GALLERY-13` — O picker abre pelo evento `lotep:open-gallery-picker-<pickerId>`, devolve ao callback `(id, url, alt, mimeType, description)` e fecha o modal em caso de sucesso ou falha do PUT. Fonte: `main-orchestrator.blade.php:61-83`; `gallery-loader.blade.php:54-59`; `detail-manager.blade.php:223-246`.
- [ ] `PC-GALLERY-14` — Arquivar/restaurar pelo detalhe atualiza o título do botão conforme `viewArchived` e recarrega a grade. Fonte: `detail-manager.blade.php:68-73,255-276`.

## 12. Evidências consultadas

- `V2/Services/Gallery/README.md:1-34`
- `V2/Services/Gallery/Routes/cms.php:1-13`
- `V2/Services/Gallery/Controllers/GalleryController.php:1-315`
- `V2/Services/Gallery/Services/GalleryService.php:1-524`
- `V2/Services/Gallery/DTOs/GalleryConfig.php:1-70`
- `V2/Services/Gallery/Consumer/GallerySender.php:1-133`
- `V2/Services/Gallery/Models/GalleryMedia.php:1-29`
- `V2/Services/Gallery/Migrations/2026_06_28_000000_create_v2_gallery_media_table.php:1-80`
- `V2/Services/Gallery/Console/Commands/PopulateMissingImages.php:1-333`
- `V2/Services/Gallery/Console/Commands/PopulateMissingVariants.php:1-95`
- `V2/Services/Gallery/Views/components/` (11 arquivos, ver `ui-contrato.md`)
- `V2/Services/Media/Services/MediaInputer.php:100-275`
- `V2/Services/Storage/StorageService.php:69-229`
- `V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`
- `V2/Core/Providers/V2ServiceProvider.php:168-208`
- `V2/Core/Middleware/CmsAuthenticate.php:1-45`
- `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:328-472`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalCmsController.php:320-436`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:526-532`
- `V2/Modules/Institucional/AssocieSe/Controllers/MembershipPlanController.php:213-239`
- `V2/Modules/Layouts/BasicPage/Services/BasicPageAssetManagerService.php:54-167`
- `V2/Modules/Support/StructuralLandingEditor/Services/AssetManagerService.php:56-180`
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:349-353,431-440`
- `V2/Modules/Publicacoes/Noticias/Models/News.php:146-235`
- `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:288-332`
- `V2/Services/LandingSectionsEditor/Controllers/LandingSectionsEditorController.php:40-70,260-370`
- `V2/Services/ContentRichEditor/Assets/js/modals.js:225-243`
- `V2/layouts/cms/layouts/app.blade.php:139`
- `config/filesystems.php:24`
- `app/Console/Kernel.php:14-22`
