# Arquivo (Drive) — Plano de Migração para Laravel Moderno

> **Alvo:** PHP 8.x + Laravel 11/12 ("v7").
> Este é um **plano de reconstrução**, não uma implementação.

---

## 1. Decisões de arquitetura (resolver **antes** de codificar)

| # | Decisão | Alternativas | Impacto |
|---|---|---|---|
| D1 | **Escopo do vínculo**: coluna polimórfica única (`attachable_type` + `attachable_id`) **vs.** manter as 7 colunas `*_id` | (a) `morphs`; (b) colunas como hoje | (a) é mais limpo e migra `contents_id`/`static_pages_id`/`calendar_events_id`; mas as FKs para `biddings`/`requerimento_diarias`/`highlights` passam a ser "lógicas" (sem integridade referencial no banco). (b) preserva as FKs mas mantém a confusão. **Recomendação:** (a) + uma tabela `archive_attachments` para os vínculos que realmente exigem FK (licitações, contratos, diária). |
| D2 | **Categorias**: recriar a categorização da v5 **sim** ou **não** | (a) recriar `belongsTo` com `category_id`; (b) `belongsToMany` (N:N); (c) não recriar | A v5 é 1:N e a UI só oferece **uma** categoria por arquivo. (b) é mais flexível e resolve "um arquivo em várias pastas", mas muda a UX. **Recomendação:** (b) `belongsToMany` com pivot, **se** o negócio pedir múltiplas categorias; caso contrário (a). |
| D3 | **Ordenação**: `order` por escopo vs. posição global | (a) `order` integer por escopo; (b) coluna `position` em tabela pivot | (a) é mais simples e já existe. **Recomendação:** (a), com índice composto e reatribuição contígua em transação. |
| D4 | **Armazenamento**: disco local `public` vs. S3/compatível | (a) `Storage::disk('public')`; (b) S3 | O storage atual é local com symlink `public/storage`. (b) é o ideal para um portal multi-município, mas exige migração de binários. **Recomendação:** abstrair atrás de `Storage::` desde o dia 1 (disco configurável), migrar binário depois. |
| D5 | **Exclusão**: soft delete que apaga o binário vs. hard delete | — | O comportamento observável da v5 é "some da listagem, continua no disco". **Não pode mudar** sem decisão do negócio (o portal tem links diretos para `/storage/…` publicados). **Recomendação:** manter soft delete + **job** de limpeza com janela de graça (ex.: 30 dias). |
| D6 | **Exposição**: `path` no banco com prefixo `storage/` vs. caminho relativo | — | Na v5 é inconsistente. **Recomendação:** gravar **caminho relativo ao disco** (`content/midias/abc.png`) e expor via accessor `public_url` que prepende `/storage/`. |

## 2. Schema de migração Laravel 11/12

### 2.1 `archives`

```php
// database/migrations/2026_XX_XX_create_archives_table.php
Schema::create('archives', function (Blueprint $table) {
    $table->id();
    $table->string('title', 300)->nullable();
    $table->text('description')->nullable();
    $table->string('type', 191)->nullable();      // MIME
    $table->string('path', 500);                   // caminho relativo ao disco
    $table->string('name', 300)->nullable();
    $table->string('extension', 191)->nullable();
    $table->string('mime_type', 191)->nullable();
    $table->unsignedBigInteger('size_bytes')->nullable();
    $table->string('hash', 64)->nullable();        // sha256 (não md5)
    $table->unsignedBigInteger('category_id')->nullable();
    $table->unsignedBigInteger('type_size_id')->nullable();
    $table->string('width', 191)->nullable();
    $table->string('height', 191)->nullable();
    $table->boolean('view_pdf')->default(false);
    $table->boolean('is_featured')->default(false);   // substitui 'highlight'
    $table->unsignedInteger('position')->default(0);   // substitui 'order'
    $table->morphs('attachable');                      // attachable_type + attachable_id
    $table->foreignId('user_id')->nullable();           // autoria
    $table->timestamps();
    $table->softDeletes();

    $table->foreign('category_id')->references('id')->on('archive_categories')->nullOnDelete();
    $table->foreign('type_size_id')->references('id')->on('types_sizes')->nullOnDelete();
    $table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
    $table->index(['attachable_type', 'attachable_id', 'position']);
    $table->index('hash');
});
```

### 2.2 `archive_categories`

```php
Schema::create('archive_categories', function (Blueprint $table) {
    $table->id();
    $table->string('name', 100);
    $table->string('slug', 100)->unique();
    $table->string('description', 500)->nullable();
    $table->unsignedInteger('position')->default(0);
    $table->timestamps();
    $table->softDeletes();
});
```

Seeder (reproduzindo a v5):

```php
// database/seeders/ArchiveCategorySeeder.php
foreach ([
    ['Documentos Oficiais', 'documentos-oficiais'],
    ['Relatórios', 'relatorios'],
    ['Fotos', 'fotos'],
    ['Vídeos', 'videos'],
] as $i => [$name, $slug]) {
    ArchiveCategory::create(['name' => $name, 'slug' => $slug, 'position' => $i + 1]);
}
```

### 2.3 Migration de dados (v5 → v7)

```php
// 1) mapear as colunas polimórficas
DB::table('archives')->orderBy('id')->chunkById(500, function ($rows) {
    foreach ($rows as $r) {
        [$type, $id] = match (true) {
            $r->contents_id        !== null => ['content', $r->contents_id],
            $r->static_pages_id    !== null => ['static_page', $r->static_pages_id],
            $r->calendar_events_id !== null => ['calendar_event', $r->calendar_events_id],
            $r->biddings_id        !== null => ['bidding', $r->biddings_id],
            $r->requerimento_id    !== null => ['daily_requirement', $r->requerimento_id],
            $r->highlight_id       !== null => ['highlight', $r->highlight_id],
            default                            => [null, null],
        };
        DB::table('archives')->where('id', $r->id)->update([
            'attachable_type' => $type,
            'attachable_id'   => $id,
            'is_featured'     => (bool) $r->highlight,
            'position'        => $r->order ?? 0,
        ]);
    }
});

// 2) normalizar o path (remover 'storage/' e URLs absolutas)
DB::table('archives')->orderBy('id')->each(function ($r) {
    $path = $r->path;
    $path = preg_replace('#^https?://[^/]+/#', '', $path);
    $path = preg_replace('#^storage/#', '', $path);
    DB::table('archives')->where('id', $r->id)->update(['path' => $path]);
});
```

> ⚠ A migração de `path` **exige** mover/renomear os binários no storage
> ou manter o prefixo `storage/` na coluna. Como há formatos misturados
> (ver `02-MODELAGEM.md` §1.4), **o ideal é reescrever os arquivos** para
> o novo layout (`{uuid}.{ext}`) e gravar o novo caminho. Isso deve ser um
> **job de idempotência**, não parte da migration.

### 2.4 Pivots

| Tabela v5 | Destino na v7 |
|---|---|
| `work_archives` | `archive_work` (pivot, com `type` como enum) |
| `biddings_has_archives` | `archive_bidding` |
| `answer_archives` | `archive_answer` |
| `contract_archives` | `archive_contract` |
| `ombudsman_answer_archives` | `archive_ombudsman_answer` |
| `emendas_has_archives` | `archive_amendment` (precisa de PK!) |

Todos os pivots novos devem ter `id`, `timestamps()` e **unique** na
combinação das duas FKs.

## 3. Modelos

### 3.1 `App\Models\Archive`

```php
class Archive extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'description', 'type', 'mime_type', 'path', 'name',
        'extension', 'size_bytes', 'hash', 'category_id', 'type_size_id',
        'width', 'height', 'view_pdf', 'is_featured', 'position',
    ];

    protected $casts = [
        'view_pdf'   => 'boolean',
        'is_featured'=> 'boolean',
        'position'   => 'integer',
        'width'      => 'integer',
        'height'     => 'integer',
        'size_bytes' => 'integer',
    ];

    // --- relações ---
    public function attachable(): MorphTo
    {
        return $this->morphTo();
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(ArchiveCategory::class);
    }

    public function typeSize(): BelongsTo
    {
        return $this->belongsTo(TypeSize::class, 'type_size_id');
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function works(): BelongsToMany
    {
        return $this->belongsToMany(Work::class, 'archive_work')
                    ->withPivot('type');
    }

    public function biddings(): BelongsToMany
    {
        return $this->belongsToMany(Bidding::class, 'archive_bidding');
    }

    // --- scopes ---
    public function scopeGalleries(Builder $q): Builder
    {
        return $q->where('is_featured', false)
                 ->whereNotNull('extension')
                 ->orderByDesc('position')
                 ->orderByDesc('id');
    }

    public function scopeImages(Builder $q): Builder
    {
        return $q->whereIn('extension', ['jpg','jpeg','png','gif','bmp','tiff','svg','webp','ico','heic','raw']);
    }

    // --- accessor ---
    public function getPublicUrlAttribute(): string
    {
        return Storage::disk('public')->url($this->path);
    }
}
```

### 3.2 `App\Models\ArchiveCategory`

```php
class ArchiveCategory extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['name', 'slug', 'description', 'position'];

    protected static function booted(): void
    {
        static::saving(function (self $c) {
            $c->slug = $c->slug ?: Str::slug($c->name);
        });
    }

    public function archives(): BelongsTo      // ou HasMany, conforme D2
    {
        return $this->belongsToMany(Archive::class, 'archive_category');
    }
}
```

> `Str::slug()` **minúsculiza e troca hífen por hífen**, ao contrário do
> `slug_fix()` da v5 (que apaga o hífen e preserva a caixa) — ver
> `05-REGRAS-E-PORTABILIDADE.md` A09.

### 3.3 Enums sugeridos

```php
enum ArchiveAttachmentType: string
{
    case Content         = 'content';
    case StaticPage      = 'static_page';
    case CalendarEvent   = 'calendar_event';
    case Bidding         = 'bidding';
    case Contract        = 'contract';
    case Work            = 'work';
    case Amendment       = 'amendment';
    case OmbudsmanAnswer= 'ombudsman_answer';
    case DailyRequirement= 'daily_requirement';
    case Highlight       = 'highlight';
}

enum WorkArchiveType: string
{
    case Gallery = 'gallery';
    case File    = 'file';
}
```

## 4. Form Requests

### 4.1 `StoreArchiveRequest`

```php
class StoreArchiveRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Archive::class);
    }

    public function rules(): array
    {
        return [
            'file'      => ['required','file','max:128000'],           // 128 MB (mesmo limite do front)
            'name'      => ['nullable','string','max:300'],
            'title'     => ['nullable','string','max:300'],
            'description'=> ['nullable','string','max:2000'],
            'category_id'=> ['nullable','integer','exists:archive_categories,id'],
            'attachable_type' => ['nullable', Rule::enum(ArchiveAttachmentType::class)],
            'attachable_id'   => ['nullable','integer'],
            'view_pdf'  => ['nullable','boolean'],
        ];
    }

    public function withValidator(Validator $v): void
    {
        $v->after(function (Validator $v) {
            // valida a extensão contra o tipo real do arquivo
        });
    }
}
```

### 4.2 `StoreArchiveCategoryRequest`

```php
class StoreArchiveCategoryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', ArchiveCategory::class);
    }

    public function rules(): array
    {
        return [
            'name' => ['required','string','max:100'],
            'slug' => ['nullable','string','max:100','unique:archive_categories,slug'],
        ];
    }

    protected function prepareForValidation(): void
    {
        if (! $this->filled('slug')) {
            $this->merge(['slug' => Str::slug($this->input('name'))]);
        }
    }
}
```

### 4.3 `ReorderArchivesRequest`

```php
class ReorderArchivesRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', Archive::class);
    }

    public function rules(): array
    {
        return [
            'attachable_type' => ['required', Rule::enum(ArchiveAttachmentType::class)],
            'attachable_id'   => ['required','integer'],
            'items' => ['required','array','min:1'],
            'items.*.id'     => ['required','integer','distinct'],
            'items.*.position'=> ['required','integer','min:0'],
        ];
    }
}
```

## 5. Policies / autorização

**Este é o item mais urgente**: a v5 responde **sem autenticação** nas
rotas do Drive (verificado em execução —
`09-PERMISSOES-E-COMPARATIVO-V6.md` §4.2).

```php
class ArchivePolicy
{
    public function viewAny(User $u): bool   { return $u->can('drive.view'); }
    public function view(User $u, Archive $a): bool { return $u->can('drive.view'); }
    public function create(User $u): bool    { return $u->can('drive.create'); }
    public function update(User $u, Archive $a): bool { return $u->can('drive.update'); }
    public function delete(User $u, Archive $a): bool { return $u->can('drive.delete'); }
    public function reorder(User $u): bool   { return $u->can('drive.update'); }
}
```

Regras adicionais:

- `check.logged` em **todas** as rotas do CMS (não confiar só na Policy).
- No portal, **apenas leitura** e apenas de arquivos publicados.
- Anexar arquivo a um conteúdo herda a permissão do conteúdo
  (`content.edit`), não apenas `drive.create`.
