Skip to content

feat(ybira): fase 2 — pastas, lixeira, paginação, download e cota - #151

Merged
zoedsoupe merged 4 commits into
mainfrom
feat/ybira-fase-2
Jun 10, 2026
Merged

feat(ybira): fase 2 — pastas, lixeira, paginação, download e cota#151
zoedsoupe merged 4 commits into
mainfrom
feat/ybira-fase-2

Conversation

@zoedsoupe

@zoedsoupe zoedsoupe commented Jun 10, 2026

Copy link
Copy Markdown
Member

Problema

A aplicação Taina precisava avançar a fase 2 do Ybira (RFC 002 §5): adicionar suporte a pastas hierárquicas, lixeira, paginação eficiente, validação segura de tipos de arquivo por assinatura binária, downloads com HTTP Range, controle de cotas e jobs agendados para purge de itens deletados.

Solução

  • Pastas: CRUD (criar, obter, renomear, mover, deletar) com prevenção de referências circulares e soft delete em cascata.
  • Soft delete e lixeira: delete_file marca deleted_at (arquivo mantido em disco); restore_file e list_trash adicionados; PurgeTrash (Oban cron 03:00) remove permanentemente itens com >30 dias.
  • Paginação keyset: cursor opaco para list_files, list_trash e list_folder_contents.
  • Detecção MIME: Taina.Ybira.MimeDetector valida por magic bytes (imagens, vídeo, áudio, PDF, ZIP, executáveis) com fallback seguro; uploads validados por allowlist e executáveis rejeitados.
  • Downloads: FileController com suporte a Range (206/416) e autenticação por sessão.
  • Quotas: Maraca.update_tekoa_quota (admin-only) e Ybira.storage_stats; ajuste de storage_used ao purgar.
  • Oban: integração (migração, config, supervisor) com Cron e modo inline em testes.
  • Contrato: Taina.Ybira.Behaviour padroniza API do domínio.
  • Repo guard: invertido para whitelist de tabelas de infraestrutura (ex.: oban_*).

Explicação

Escolhas técnicas visam segurança e escalabilidade: soft delete permite recuperação imediata enquanto purge periódico recupera espaço e ajusta cota; keyset melhora desempenho em grandes listas; detecção por magic bytes evita confiança em extensões fáceis de falsificar; HTTP Range economiza banda em downloads; Behaviour isola contrato do domínio; e whitelist explícita no Repo evita vazamento silencioso de dados.

Implements Maraca.Behaviour: bootstrap (single-tekoa first boot),
invite-by-link (no email dependency, RFC 002 D6), email confirmation,
authentication, sessions, password reset, owner-first authorization
with explicit permissions and admin access requests.

- avas.username now nullable: invite flow sets it at confirmation
- Permission.changeset no longer casts granted_by_id/tekoa_id
  (anti-spoofing — context fields set only via put_change)
- destroy_session clears and drops the session (fixation-safe)
@zoedsoupe zoedsoupe self-assigned this Jun 10, 2026
@zoedsoupe

Copy link
Copy Markdown
Member Author

@coderabbitai review, leve em consideração a nova RFC do tekoa https://github.com/taina-labs/tekoa/blob/main/tecnico/RFC_002_MVP.md

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the RFC content
gh api repos/taina-labs/tekoa/contents/tecnico/RFC_002_MVP.md --jq '.content' | base64 -d 2>/dev/null || \
  curl -s "https://raw.githubusercontent.com/taina-labs/tekoa/main/tecnico/RFC_002_MVP.md" 2>/dev/null || \
  echo "Could not fetch RFC directly"

echo "=== PR FILES ==="
gh pr view 151 --repo taina-labs/taina --json files --jq '.files[].path' 2>/dev/null

echo "=== PR DIFF (truncated) ==="
gh pr diff 151 --repo taina-labs/taina 2>/dev/null | head -2000

Length of output: 89694


🏁 Script executed:

#!/bin/bash
echo "=== REMAINING DIFF ==="
gh pr diff 151 --repo taina-labs/taina 2>/dev/null | tail -n +2001 | head -2000

echo "=== Scope module ==="
fd "scope.ex" --exec cat {}

echo "=== Maraca Tekoa schema ==="
fd "tekoa.ex" --exec cat {}

echo "=== Ybira File schema (full) ==="
fd "file.ex" --exec grep -n "field\|schema\|tekoa_id\|deleted_at\|folder_id" {}

echo "=== Check Scope.for_ava usage ==="
rg "for_ava" --type elixir -n

echo "=== Check storage layout in copy_to_storage ==="
rg "copy_to_storage\|storage_root\|ano\|mes\|year\|month" lib/taina/ybira.ex -n -A3

echo "=== Check purge_deleted_files file rm order ==="
rg "purge_deleted\|File.rm\|Repo.delete" lib/taina/ybira.ex -n -A2

echo "=== ancestor_or_self recursion (depth guard?) ==="
rg "ancestor_or_self" lib/taina/ybira.ex -n -A5

echo "=== list_permissions skip_tekoa ==="
rg "list_permissions\|skip_tekoa" lib/taina/maraca.ex -n -A3

echo "=== Permission changeset - granted_by_id ==="
cat lib/taina/maraca/permission.ex | grep -A20 "def changeset"

echo "=== delete_folder soft delete (folders missing from purge?) ==="
rg "purge_deleted\|deleted_at.*folder" lib/taina/ybira.ex -n -A3

echo "=== Oban testing config ==="
cat config/test.exs

echo "=== Check Repo.exists? signature ==="
rg "def exists\?" lib/taina/repo.ex -n -A5

echo "=== Check Repo.transact ==="
rg "def transact\|transact(" lib/taina/repo.ex -n -A5

Length of output: 38523



@zoedsoupe Que entrega! 🌱 A Fase 2 está bem completa e o design bate forte com as decisões da RFC 002 — o guard fail-safe do Repo, o Behaviour isolado, a detecção de MIME por magic bytes e a paginação por keyset são exatamente o que o D3/D8 pedem. Aqui vão os pontos que vale olhar com mais atenção antes do merge:


🔴 Crítico

1. purge_deleted_files/1 não limpa pastas — vazamento silencioso de registros

Taina.Ybira.purge_deleted_files/1 só consulta e apaga Ybira.File. Mas delete_folder/2 faz soft-delete em cascata de Ybira.Folder também, via soft_delete_tree/1. O resultado: pastas deletadas nunca saem do banco — e os arquivos dentro delas têm deleted_at setado (logo entram no purge de arquivos ✓), mas os registros de pasta ficam para sempre com deleted_at preenchido. Isso viola o contrato do PurgeTrash e é exatamente o tipo de vazamento silencioso que o guard do Repo foi feito para evitar.

# purge_deleted_files/1 — adicionar após o purge de arquivos:
Repo.delete_all(
  from(d in Ybira.Folder, where: not is_nil(d.deleted_at) and d.deleted_at < ^cutoff),
  skip_tekoa_id: true
)

Também vale atualizar Taina.Ybira.Behaviour's @callback purge_deleted_files para documentar que apaga pastas também, e adicionar um caso no PurgeTrashTest.


2. ancestor_or_self?/2 e soft_delete_tree/1 — recursão ilimitada + N+1 queries

Ambas as funções disparam uma query por nível de hierarquia e chamam a si mesmas recursivamente sem limite de profundidade. Para uma árvore de pastas legítima com 50 níveis isso daria 50 queries + risco real de stack overflow no Pi. O gate de benchmark da RFC §5 Fase 2 pode esconder esse problema se o benchmark usar hierarquias rasas.

Para ancestor_or_self?/2, uma CTE recursiva resolve em 1 query:

defp ancestor_or_self?(folder_id, candidate_id) do
  # Uma única query CTE recursiva sobe toda a cadeia de ancestrais
  query =
    """
    WITH RECURSIVE ancestors AS (
      SELECT id, parent_id FROM ybira.folders WHERE id = $1
      UNION ALL
      SELECT f.id, f.parent_id FROM ybira.folders f
      JOIN ancestors a ON f.id = a.parent_id
    )
    SELECT COUNT(*) FROM ancestors WHERE id = $2
    """
  {:ok, %{rows: [[count]]}} = Taina.Repo.query(query, [folder_id, candidate_id])
  count > 0
end

Para soft_delete_tree/1, uma abordagem similar com CTE evita a recursão Elixir.


3. Permission.changesetgranted_by_id saiu do validate_required

O changeset agora usa apenas cast/4 com [:resource_id, :resource_type, :action, :ava_id] e validate_required com os mesmos quatro campos. insert_grant injeta granted_by_id via put_change/3, o que funciona hoje — mas se qualquer outro caminho chamar Permission.changeset diretamente (e já existe approve_access_request/2 que faz isso), granted_by_id pode ficar nil silenciosamente. O campo é trilha de auditoria; vale ou adicionar de volta ao validate_required, ou criar um changeset específico grant_changeset/2 que exige o campo:

def grant_changeset(%__MODULE__{} = permission, %{} = attrs) do
  permission
  |> cast(attrs, [:resource_id, :resource_type, :action, :ava_id, :granted_by_id, :tekoa_id])
  |> validate_required([:resource_id, :resource_type, :action, :ava_id, :granted_by_id, :tekoa_id])
  |> unique_constraint(:action, name: "permissions_unique_grant", ...)
end

🟡 Médio

4. Inconsistência entre delete_file/2 e restore_file/2

delete_file/2 filtra estritamente por ava_id == scope.ava.id — admin não consegue deletar o arquivo de outro usuário. Mas restore_file/2 usa owns_or_admin?/2. Se é design intencional (admin restaura mas não deleta), vale documentar no @callback do Behaviour. Se não, a lógica de delete_file deveria também aceitar admin.

5. list_trash/2 — admin não consegue ver a lixeira de outros usuários

from f in Ybira.File,
  where: not is_nil(f.deleted_at) and f.ava_id == ^scope.ava.id  # ← só o próprio usuário

Para o PurgeTrashTest e o fluxo de admin gerenciar lixeira, vale pensar se um admin deve poder chamar list_trash/2 e ver todos os arquivos deletados da tekoa. A RFC §5 menciona "gestão de cota" — um admin sem visibilidade na lixeira não consegue auditar por que a cota continua alta.

6. FileController — sem Content-Disposition

O download serve o arquivo sem o header Content-Disposition. Sem ele, o browser decide sozinho entre exibir inline (PDF, imagem) ou baixar. Vale ao menos para tipos que não devem ser executados no browser (zip, octet-stream):

disposition =
  if String.starts_with?(file.mime_type, ["image/", "video/", "audio/", "application/pdf"]),
    do: ~s(inline; filename="#{file.original_filename}"),
    else: ~s(attachment; filename="#{file.original_filename}")

conn |> put_resp_header("content-disposition", disposition)

7. purge_deleted_files/1 — loop arquivo por arquivo

O purge usa Enum.each com Repo.transaction individual por arquivo. Para uma lixeira com 1.000 arquivos no Pi 5 isso são 1.000 transações sequenciais. Considerar um Repo.delete_all em batch para a parte do banco (já que Elixir.File.rm precisa ser individual de qualquer jeito):

# Banco em um shot
{count, deleted_files} =
  Repo.delete_all(
    from(f in Ybira.File,
      where: not is_nil(f.deleted_at) and f.deleted_at < ^cutoff,
      select: f),
    skip_tekoa_id: true
  )

# Cota por tekoa em batch (group_by)
# Disco: Enum.each dos deleted_files

🔵 Baixo / Nitpick

MimeDetector — EBML header (0x1A 0x45 0xDF 0xA3) abrange também Matroska (.mkv)
O magic byte 0x1A 0x45 0xDF 0xA3 é o cabeçalho EBML genérico, usado por WebM e MKV. .mkv vai ser classificado como video/webm. Não é problema de segurança (ambos estão na allowlist), mas pode causar confusão na interface futura. Matroska usa o brand matroska no DocType EBML — detectá-lo exigiria ler mais bytes, o que talvez não valha no MVP.

FileController — sem ETag/Last-Modified
Resumos de cache ajudam muito para mídia num Pi com banda limitada. Baixo esforço: put_resp_header("last-modified", ...) usando file.inserted_at.

Scope.for_ava/1 — FunctionClauseError se tekoa não precarregada
Só tem um call site hoje (FileController), onde get_session_user/1 já precarrega. Mas um @doc explícito avisando a exigência do preload previne surpresas futuras.


✅ O que está muito bom

  • Guard fail-safe do Repo com @infra_table_prefixes — exatamente o que a RFC D3 pede: fail loud, never silent leak.
  • MimeDetector puro Elixir sem NIFs — perfeito para ARM/Pi, cobre executáveis disfarçados.
  • Paginação por keyset com cursor opaco — eficiente e alinhada com a justificativa da RFC (id serial monótono).
  • Oban inline em testes dentro do sandbox Ecto — setup correto.
  • bootstrap/2 + índice único — implementa D2 na íntegra.
  • soft_delete_tree não devolve cota — design intencional e correto, o PurgeTrash é o único ponto de recuperação.
  • Suíte de testes bem cobrindo os fluxos de negócio críticos.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cfa1c8d1-4620-43d8-8ddd-86dfc91e7293

📥 Commits

Reviewing files that changed from the base of the PR and between 4155dae and 019079a.

⛔ Files ignored due to path filters (1)
  • mix.lock is excluded by !**/*.lock
📒 Files selected for processing (35)
  • .github/ISSUE_TEMPLATE/comunidade---governança.md
  • .github/ISSUE_TEMPLATE/desenvolvimento.md
  • .github/ISSUE_TEMPLATE/design.md
  • .github/ISSUE_TEMPLATE/documentação.md
  • .github/ISSUE_TEMPLATE/economia-e-custos.md
  • .github/workflows/ci.yml
  • config/config.exs
  • config/test.exs
  • lib/taina/application.ex
  • lib/taina/maraca.ex
  • lib/taina/maraca/ava.ex
  • lib/taina/maraca/behaviour.ex
  • lib/taina/maraca/permission.ex
  • lib/taina/maraca/unauthorized_error.ex
  • lib/taina/repo.ex
  • lib/taina/ybira.ex
  • lib/taina/ybira/behaviour.ex
  • lib/taina/ybira/file.ex
  • lib/taina/ybira/folder.ex
  • lib/taina/ybira/mime_detector.ex
  • lib/taina/ybira/workers/purge_trash.ex
  • lib/taina_web/controllers/file_controller.ex
  • lib/taina_web/router.ex
  • mix.exs
  • priv/repo/migrations/20260610120000_allow_pending_invite_username.exs
  • priv/repo/migrations/20260610130000_setup_oban.exs
  • priv/repo/migrations/20260610130001_ybira_fase2_indexes.exs
  • test/support/conn_case.ex
  • test/support/fixtures.ex
  • test/taina/maraca_test.exs
  • test/taina/ybira/mime_detector_test.exs
  • test/taina/ybira/purge_trash_test.exs
  • test/taina/ybira_test.exs
  • test/taina_web/controllers/file_controller_test.exs
  • test/test_helper.exs

Walkthrough

Este PR implementa a Fase 2 do Ybira com soft delete e pasta, integra Oban para jobs agendados, expande Maraca com novos contratos de autorização, e entrega download HTTP com suporte a Range. O fluxo central transforma o ciclo de vida de arquivos de hard delete para soft delete com trash/restore, adiciona CRUD completo de pastas com validação de ciclos, paginação por cursor, detecção MIME por magic bytes, e agendamento automático de purga a cada 30 dias via worker. Inclui controller autenticado para download com RFC 7233 (parciais), refresh da infraestrutura de teste, e migrations de schema com índices otimizados.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • taina-labs/taina#14: Migrações Ybira (deleted_at em folders e índices para keyset) correspondem às mudanças de schema e índices presentes aqui.
  • taina-labs/taina#35: Também solicita alterações em migrations/índices de Ybira que este PR implementa (soft delete e índices para paginação).

Possibly related PRs

  • taina-labs/taina#149: Ajuste de Permission.changeset para tratar granted_by_id/tekoa_id como contexto, diretamente relacionado às mudanças em Permission deste PR.

Poem

🗂️ Pastas e lixeiras dançam em silêncio, purge ronda às três,
Bytes checam suas assinaturas — PNG sorri, EXE é barrado,
Oban cochila no supervisor e acorda para limpar o passado,
Maraca guarda permissões com rigor, sessão abre caminho,
Range serve só o que é pedido — pequenos pedaços, muita dignidade.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning A descrição do PR está vazia. O template exige seções Problem, Solution e Rationale, mas nenhuma foi preenchida. Preencha a descrição seguindo o template: descreva qual problema a Fase 2 do Ybira resolve, como foi implementado (Oban, MIME detection, pastas, lixeira, paginação, download, quota) e a rationale arquitetural (contextos limpos, multi-tenancy, soft deletes com purge agendado).
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed O título reflete fielmente a essência da mudança: implementação da fase 2 do Ybira com pastas, lixeira, paginação, download e controle de cota.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ybira-fase-2

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/taina/ybira/file.ex (1)

102-134: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Campo programático deleted_at está sendo aceito via cast.

O deleted_at é sempre definido programaticamente através de delete_changeset/1 e restore_changeset/1, nunca devendo vir de input externo. Incluí-lo no cast (linha 112) abre a possibilidade de attrs de usuário sobrescreverem o soft delete inadvertidamente.

Que tal removê-lo do cast para reforçar que é estritamente programático?

🔧 Sugestão de ajuste
   def changeset(file, attrs) do
     file
     |> cast(attrs, [
       :filename,
       :original_filename,
       :filepath,
       :mime_type,
       :file_size_bytes,
       :file_hash,
       :metadata,
-      :deleted_at,
       :ava_id,
       :tekoa_id,
       :folder_id
     ])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/taina/ybira/file.ex` around lines 102 - 134, Remove :deleted_at from the
cast list in the changeset/2 function so external attrs cannot set the
soft-delete flag; keep deleted_at managed only by the programmatic
delete_changeset/1 and restore_changeset/1 helpers and ensure any existing
references to :deleted_at remain only in those functions and not in changeset/2
or other user-input paths.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/config.exs`:
- Around line 14-20: Ajuste a entrada do cron em Oban para explicitar a fila do
job de PurgeTrash: edite o plugin {Oban.Plugins.Cron, crontab: [...] } e na
tupla crontab substitua a referência {"0 3 * * *",
Taina.Ybira.Workers.PurgeTrash} por {"0 3 * * *",
Taina.Ybira.Workers.PurgeTrash, queue: :default} (ou use queue: :maintenance se
quiser isolar tarefas de manutenção); isso garante que o worker
Taina.Ybira.Workers.PurgeTrash execute na fila desejada em vez de depender do
comportamento padrão.

In `@lib/taina_web/controllers/file_controller.ex`:
- Around line 71-80: A função suffix_range/2 pode retornar um last negativo
quando total == 0 (arquivo vazio) e um cliente pede bytes=-N; atualize
suffix_range(suffix, total) para validar total > 0 e garantir que o intervalo
calculado seja válido antes de retornar: ao parsear {len, ""} verifique primeiro
total > 0, calcule first = max(total - len, 0) e only return {:ok, first, total
- 1} se first <= total - 1; caso contrário (total == 0 ou first > total - 1)
retorne :error para evitar last negativo e problemas em send_file/2.

In `@lib/taina/maraca/unauthorized_error.ex`:
- Line 17: The string interpolation uses `ava && ava.public_id`, which can yield
"false" in edge cases; update the code to pattern-match `ava` explicitly and use
its `public_id` (e.g., match `%Ava{public_id: public_id}` in the function head
or a preceding `case`/`with`) and interpolate `public_id` instead of `ava &&
ava.public_id`; locate this change in the module/function that builds the
unauthorized message (referencing `ava.public_id` and the surrounding message
construction in UnauthorizedError) and ensure the fallback when `ava` is
nil/absent yields an empty or descriptive value rather than "false".

In `@lib/taina/repo.ex`:
- Around line 144-152: Add a debug log when an infrastructure table is detected
by infra_query?/1: inside the clause defp infra_query?(%Ecto.Query{from:
%{source: {table, _schema}}}) when is_binary(table) do, keep the existing
String.starts_with?(`@infra_table_prefixes`) check but, when true, call
Logger.debug with a concise message like "infra query permitida:
tabela=#{table}" (ensure require Logger is present); return true for the match
and false otherwise so behavior is unchanged while adding observability.

In `@lib/taina/ybira.ex`:
- Around line 499-505: apply_cursor currently swallows decode_cursor failures
and returns the original query, causing clients with invalid/expired cursors to
silently receive the first page; update apply_cursor (and call sites like
list_files/3) to surface invalid cursors instead of silently falling back:
change apply_cursor/2 to return an explicit error tuple (e.g., {:error,
:invalid_cursor}) when decode_cursor returns :error and update list_files/3 to
propagate that error to the caller (or alternatively log the invalid cursor and
return {:error, :invalid_cursor} from list_files/3) so clients receive a clear
failure instead of silent pagination reset.
- Around line 309-311: The call to Elixir.File.rm(file.filepath) in ybira.ex
currently ignores errors so orphaned files can accumulate silently; update the
cleanup to pattern-match the result of Elixir.File.rm and log failures via
Logger (e.g. Logger.warning) including the filepath and file.id (or other
identifier) and the error reason so disk/permission issues are visible and
traceable; ensure successful :ok still proceeds without raising.
- Around line 430-449: soft_delete_tree currently traverses folders recursively
(functions: soft_delete_tree, Repo.all selecting Ybira.Folder ids,
Repo.update_all on Ybira.File/Ybira.Folder) which issues many sequential queries
for deep trees; to fix, replace the recursive traversal with a single recursive
CTE that selects all descendant folder ids and then perform two bulk updates
(one on Ybira.File where folder_id IN CTE ids and one on Ybira.Folder where id
IN CTE ids) or alternatively offload the existing soft_delete_tree work to an
asynchronous worker (e.g., Oban/Task) so deletions for large trees run outside
the request and avoid timeouts; pick CTE for DB-side efficiency or worker for
deferred execution and update the soft_delete_tree function to call the chosen
approach.
- Around line 414-428: The ancestor_or_self?/2 recursion can blow the stack and
issue many DB queries for deep trees; change it to a depth-limited iterative
approach (or use a recursive CTE) to avoid deep recursion and N queries:
implement a loop in ancestor_or_self?/2 that queries parent_id iteratively up to
a MAX_DEPTH (e.g. 50) and return {:error, :too_deep} or false when exceeded, and
apply the same refactor to soft_delete_tree/1 (replace recursive calls with an
iterative traversal or single recursive CTE delete) so both functions no longer
recurse per level and enforce a clear depth limit or use a single-query
recursive CTE for large hierarchies.

In `@lib/taina/ybira/workers/purge_trash.ex`:
- Around line 19-23: O worker perform/1 em purge_trash.ex não expõe métricas ou
logs sobre a purga; ao chamar Ybira.purge_deleted_files(cutoff) capture o
retorno (atualmente {:ok, _purged}) e emita um log estruturado ou uma métrica
contendo pelo menos o número de arquivos removidos e bytes recuperados; por
exemplo, inspecione a estrutura retornada por purge_deleted_files (nome
identificador: _purged) para extrair counts e bytes, use Logger.info com
map/keyword ou dispare um evento Telemetry.emit/3 com esses campos, e mantenha a
função perform/1 retornando :ok após o envio do log/métrica.

In `@priv/repo/migrations/20260610120000_allow_pending_invite_username.exs`:
- Around line 10-13: Replace the single change/0 that uses execute/2 with
explicit up/0 and down/0 functions to make the migration intent clearer:
implement def up do execute "ALTER TABLE maraca.avas ALTER COLUMN username DROP
NOT NULL" end and def down do execute "ALTER TABLE maraca.avas ALTER COLUMN
username SET NOT NULL" end so the migration applies the DROP NOT NULL on up and
the SET NOT NULL on down (replace the existing change/0 implementation with
these two functions).

In `@test/taina/maraca_test.exs`:
- Around line 14-350: Extract the repeated token-expiration setup into a small
test helper (e.g. expire_token/3) and replace the duplicated DateTime.add +
Ecto.Changeset.change + Repo.update! sequences in the tests that expire tokens
(the blocks that change invited.email_confirmation_sent_at and
with_token.reset_token_sent_at) to call that helper; implement expire_token to
accept the struct (e.g. %Ava{}), the field atom (:email_confirmation_sent_at or
:reset_token_sent_at) and a tuple like {8, :day} or {2, :hour} (compute negative
duration with DateTime.add and update via Ecto.Changeset.change |>
Repo.update!()) so both tests use the helper for clarity and DRYness.

---

Outside diff comments:
In `@lib/taina/ybira/file.ex`:
- Around line 102-134: Remove :deleted_at from the cast list in the changeset/2
function so external attrs cannot set the soft-delete flag; keep deleted_at
managed only by the programmatic delete_changeset/1 and restore_changeset/1
helpers and ensure any existing references to :deleted_at remain only in those
functions and not in changeset/2 or other user-input paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d890d136-b64d-4791-977a-0137ed5bc75b

📥 Commits

Reviewing files that changed from the base of the PR and between 4155dae and b05d629.

⛔ Files ignored due to path filters (1)
  • mix.lock is excluded by !**/*.lock
📒 Files selected for processing (29)
  • config/config.exs
  • config/test.exs
  • lib/taina/application.ex
  • lib/taina/maraca.ex
  • lib/taina/maraca/ava.ex
  • lib/taina/maraca/behaviour.ex
  • lib/taina/maraca/permission.ex
  • lib/taina/maraca/unauthorized_error.ex
  • lib/taina/repo.ex
  • lib/taina/ybira.ex
  • lib/taina/ybira/behaviour.ex
  • lib/taina/ybira/file.ex
  • lib/taina/ybira/folder.ex
  • lib/taina/ybira/mime_detector.ex
  • lib/taina/ybira/workers/purge_trash.ex
  • lib/taina_web/controllers/file_controller.ex
  • lib/taina_web/router.ex
  • mix.exs
  • priv/repo/migrations/20260610120000_allow_pending_invite_username.exs
  • priv/repo/migrations/20260610130000_setup_oban.exs
  • priv/repo/migrations/20260610130001_ybira_fase2_indexes.exs
  • test/support/conn_case.ex
  • test/support/fixtures.ex
  • test/taina/maraca_test.exs
  • test/taina/ybira/mime_detector_test.exs
  • test/taina/ybira/purge_trash_test.exs
  • test/taina/ybira_test.exs
  • test/taina_web/controllers/file_controller_test.exs
  • test/test_helper.exs

Comment thread config/config.exs
Comment thread lib/taina_web/controllers/file_controller.ex
Comment thread lib/taina/maraca/unauthorized_error.ex
Comment thread lib/taina/repo.ex
Comment thread lib/taina/ybira.ex Outdated
Comment thread lib/taina/ybira.ex Outdated
Comment thread lib/taina/ybira.ex
Comment thread lib/taina/ybira/workers/purge_trash.ex
Comment thread test/taina/maraca_test.exs
…rga de pastas; download com content-disposition
@zoedsoupe

zoedsoupe commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review the last commit, some comments were addressed, the ones not were skipped/deffered so you can safely resolve the comment

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Last commit info ==="
gh pr view 151 --repo taina-labs/taina --json commits --jq '.commits[-1]'

echo ""
echo "=== Last commit SHA ==="
LAST_SHA=$(gh pr view 151 --repo taina-labs/taina --json commits --jq '.commits[-1].oid')
echo "SHA: $LAST_SHA"

echo ""
echo "=== Last commit diff ==="
gh api repos/taina-labs/taina/commits/$LAST_SHA 2>/dev/null | jq -r '.files[] | "--- \(.filename)\n+++ \(.filename)\n" + (.patch // "")' 2>/dev/null | head -3000

Length of output: 12824



@zoedsoupe Que commit! 🌿 O salto de qualidade é nítido — deixa eu registrar o que foi resolvido e o que ficou como observação residual.


✅ Resolvido neste commit

Issue anterior O que mudou
🔴 Purge de pastas Repo.delete_all para pastas na lixeira + teste dedicado no PurgeTrashTest
🔴 Recursão ilimitada / N+1 CTEs recursivas em ancestor_or_self? e descendant_folder_ids — 1 query cada
🟡 Inconsistência delete/restore_file Ambos agora usam owner? — coerente com o RFC "admins são facilitadores, não deuses"
🟡 Content-Disposition content_disposition/1 com lógica inline/attachment correta
🔵 Last-Modified put_last_modified/2 implementado usando updated_at
Bonus Guard total > 0 em suffix_range/2, Logger.warning em falhas de disco, log de conclusão no PurgeTrash

🔵 Observação residual (não bloqueante)

CTEs usam Repo.query/2 — bypass do prepare_query/3

ancestor_or_self? e descendant_folder_ids emitem SQL cru, que não passa pelo callback prepare_query/3 do Taina.Repo (e portanto não recebe o filtro de tekoa_id automático). Hoje está seguro porque ambas as funções só recebem IDs que já passaram pela verificação de owner? no caller, mas vale deixar isso documentado:

# ATENÇÃO: `Repo.query/2` bypassa o `prepare_query/3` (sem filtro tekoa
# automático). Seguro aqui porque `root_id`/`start_id` só chegam após
# verificação de propriedade no caller — não expor esse helper sem essa garantia.
defp descendant_folder_ids(root_id) do

⏭️ Deferred (confirmado ok para este PR)

  • Permission.changeset granted_by_id auditoria
  • list_trash/2 visibilidade admin
  • Purge de arquivos ainda individual por transação (loop)
  • EBML/Matroska ambiguidade no MimeDetector
  • @doc em Scope.for_ava/1

Gostaria de abrir issues de acompanhamento para os itens deferidos? 🌱

[resolve]

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the last commit info
gh pr view 151 --repo taina-labs/taina --json commits --jq '.commits[-1]'

echo "=== Last commit diff ==="
gh api repos/taina-labs/taina/commits/$(gh pr view 151 --repo taina-labs/taina --json commits --jq '.commits[-1].oid') --jq '.files[].filename'

echo "=== Full diff of last commit ==="
LAST_SHA=$(gh pr view 151 --repo taina-labs/taina --json commits --jq '.commits[-1].oid')
gh api repos/taina-labs/taina/commits/$LAST_SHA 2>/dev/null | jq -r '.files[] | "=== \(.filename) ===\n\(.patch // "binary or no patch")"'

echo "=== My review comments on PR ==="
gh api repos/taina-labs/taina/pulls/151/comments --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, path: .path, body: .body[0:200]}'

echo "=== My issue comments on PR ==="
gh api repos/taina-labs/taina/issues/151/comments --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, body: .body[0:300]}'

Length of output: 17083


🏁 Script executed:

#!/bin/bash
# Resolve the inline review comments that were addressed in the last commit

ADDRESSED_COMMENT_IDS=(3389244213 3389244256 3389244271 3389244284 3389244296)

for id in "${ADDRESSED_COMMENT_IDS[@]}"; do
  echo "Resolving comment $id..."
  # GitHub doesn't have a direct "resolve" API for PR review comments,
  # but we can check if they're threads to resolve
  # Let's get the thread/pull request review thread IDs
  gh api repos/taina-labs/taina/pulls/151/comments/$id --jq '{id: .id, path: .path, body: .body[0:100]}'
done

echo "=== Fetching review threads to resolve ==="
gh api graphql -f query='
{
  repository(owner: "taina-labs", name: "taina") {
    pullRequest(number: 151) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              databaseId
              body
            }
          }
        }
      }
    }
  }
}'  --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {threadId: .id, commentId: .comments.nodes[0].databaseId, body: .comments.nodes[0].body[0:120]}'

Length of output: 3197


🏁 Script executed:

#!/bin/bash
# Resolve all remaining open threads (addressed + deferred/skipped by user)

THREAD_IDS=(
  "PRRT_kwDOPnKwgs6IhVmy"
  "PRRT_kwDOPnKwgs6IhVnR"
  "PRRT_kwDOPnKwgs6IhVnX"
  "PRRT_kwDOPnKwgs6IhVn3"
  "PRRT_kwDOPnKwgs6IhVn-"
  "PRRT_kwDOPnKwgs6IhVoG"
)

for thread_id in "${THREAD_IDS[@]}"; do
  echo "Resolving thread $thread_id..."
  gh api graphql -f query="
    mutation {
      resolveReviewThread(input: {threadId: \"$thread_id\"}) {
        thread {
          id
          isResolved
        }
      }
    }
  " --jq '.data.resolveReviewThread.thread'
done

echo "=== Verification: remaining unresolved threads ==="
gh api graphql -f query='
{
  repository(owner: "taina-labs", name: "taina") {
    pullRequest(number: 151) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              databaseId
              body
            }
          }
        }
      }
    }
  }
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {threadId: .id, commentId: .comments.nodes[0].databaseId}'

Length of output: 1765



@zoedsoupe Todos os threads foram resolvidos! ✅

Aqui um resumo rápido do que o último commit endereçou:

Ponto Status
purge_deleted_files não limpava pastas ✅ Resolvido — Repo.delete_all em batch para Ybira.Folder, doc do Behaviour atualizada, teste adicionado
ancestor_or_self? / soft_delete_tree N+1 + recursão ilimitada ✅ Resolvido — CTE recursiva única para ambos (ancestor_or_self? e descendant_folder_ids)
Inconsistência delete_file vs restore_file (owns_or_admin?) ✅ Resolvido — ambos agora usam owner?/2, com a decisão de design documentada ("admins são facilitadores, não deuses")
FileController sem Content-Disposition ✅ Resolvido — content_disposition/1 com lógica inline/attachment
FileController sem Last-Modified ✅ Resolvido — put_last_modified/2 adicionado
File.rm falhas silenciosas ✅ Resolvido — Logger.warning com path, file_id e reason
Observabilidade do PurgeTrash ✅ Resolvido — Logger.info com contagem e cutoff
suffix_range com arquivo vazio (total = 0) ✅ Resolvido — guard total > 0 adicionado

Os itens adiados (Permission.changeset granted_by_id, visibilidade da lixeira para admins, purge em batch, MimeDetector EBML) ficam como dívida técnica para uma próxima iteração. Gostaria de abrir issues de acompanhamento para algum deles?

[resolve]

@zoedsoupe

Copy link
Copy Markdown
Member Author

@coderabbitai, sim, pode abrir as issues seguindo os templates do projeto e com linguagem clara/explicita, humanizada por favor.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Issue templates ==="
find . -path "*/.github/ISSUE_TEMPLATE*" -o -name "*.md" | grep -i "issue_template\|ISSUE_TEMPLATE" 2>/dev/null | head -20

ls .github/ISSUE_TEMPLATE/ 2>/dev/null || ls .github/ 2>/dev/null || echo "No .github dir found"

echo ""
echo "=== Template contents ==="
fd "ISSUE_TEMPLATE\|issue_template" --type f --exec cat {} \; 2>/dev/null

echo ""
echo "=== Existing open issues (for reference) ==="
gh issue list --repo taina-labs/taina --limit 10 --json number,title,labels

echo ""
echo "=== Available labels ==="
gh label list --repo taina-labs/taina --json name,description 2>/dev/null | jq -r '.[] | "\(.name): \(.description)"'

Length of output: 749


🏁 Script executed:

#!/bin/bash
echo "=== Issue templates via API ==="
gh api repos/taina-labs/taina/contents/.github/ISSUE_TEMPLATE 2>/dev/null | jq -r '.[].name' || echo "No ISSUE_TEMPLATE folder"

echo ""
echo "=== Try root .github ==="
gh api repos/taina-labs/taina/contents/.github 2>/dev/null | jq -r '.[].name'

echo ""
echo "=== Available labels ==="
gh label list --repo taina-labs/taina --json name,description 2>/dev/null | jq -r '.[] | "\(.name): \(.description)"'

echo ""
echo "=== Existing open issues (reference) ==="
gh issue list --repo taina-labs/taina --limit 10 --json number,title,labels --jq '.[] | "#\(.number) \(.title)"'

Length of output: 1343


🏁 Script executed:

#!/bin/bash
echo "=== Template: desenvolvimento.md ==="
gh api repos/taina-labs/taina/contents/.github/ISSUE_TEMPLATE/desenvolvimento.md --jq '.content' | base64 -d

echo ""
echo "=== Template: documentação.md ==="
gh api repos/taina-labs/taina/contents/.github/ISSUE_TEMPLATE/documentação.md --jq '.content' | base64 -d

Length of output: 940


🏁 Script executed:

#!/bin/bash

PR_URL="https://github.com/taina-labs/taina/pull/151"

# Issue 1: Permission.changeset granted_by_id auditoria
gh api repos/taina-labs/taina/issues \
  --method POST \
  --field title="[Maraca] Trilha de auditoria: garantir que granted_by_id nunca seja nil em Permission" \
  --field body="**Descrição**

Atualmente, o \`Permission.changeset/2\` faz \`cast\` e \`validate_required\` apenas para \`:resource_id\`, \`:resource_type\`, \`:action\` e \`:ava_id\`. O campo \`granted_by_id\` — que representa **quem concedeu a permissão** — é injetado externamente via \`put_change/3\` por quem chama o changeset.

Isso funciona nos caminhos conhecidos (\`insert_grant\` e \`approve_access_request\`), mas deixa uma brecha silenciosa: qualquer novo caminho que chame \`Permission.changeset/2\` diretamente pode persistir uma permissão sem \`granted_by_id\`, comprometendo a trilha de auditoria sem levantar nenhum erro.

Num sistema orientado à soberania e transparência das comunidades, saber **quem** concedeu uma permissão é tão importante quanto saber **o que** foi concedido.

**Tarefas**

- [ ] Criar um \`grant_changeset/2\` específico que inclua \`granted_by_id\` e \`tekoa_id\` no \`cast\` e no \`validate_required\`
- [ ] Migrar \`insert_grant\` e \`approve_access_request\` para usar \`grant_changeset/2\`
- [ ] Adicionar teste cobrindo a rejeição de uma permissão sem \`granted_by_id\`
- [ ] Revisar se há outros call sites que chamam \`Permission.changeset\` diretamente

_Levantado durante a revisão do `#151` por `@zoedsoupe`$PR_URL_" \
  --field labels='["engineering"]' \
  --field assignees='["zoedsoupe"]' \
  --jq '"Created: #\(.number) — \(.title)\n\(.html_url)"'

echo ""

# Issue 2: list_trash/2 visibilidade admin
gh api repos/taina-labs/taina/issues \
  --method POST \
  --field title="[Ybira] Admins não conseguem ver a lixeira de outros membros da comunidade" \
  --field body="**Descrição**

A função \`list_trash/2\` filtra os arquivos deletados pelo \`ava_id\` do scope, ou seja, cada usuário enxerga **apenas sua própria lixeira**. Isso é o comportamento correto para membros comuns, mas deixa o admin sem visibilidade sobre os arquivos deletados de toda a tekoa.

O problema prático: se a cota de armazenamento de uma comunidade está alta e o owner ou admin quer entender o motivo, não consegue auditar quais arquivos estão na lixeira ocupando espaço (pois os bytes não são devolvidos no soft delete — só no purge). Isso torna a gestão de cota opaca justamente para quem tem responsabilidade sobre ela.

**Tarefas**

- [ ] Decidir e documentar no \`@callback\` do \`Taina.Ybira.Behaviour\` se admin pode listar toda a lixeira da tekoa
- [ ] Se sim: ajustar \`list_trash/2\` para que quando \`scope.ava.role == :admin\`, a query não filtre por \`ava_id\`
- [ ] Adicionar teste cobrindo o cenário de admin listando lixeira de outro membro
- [ ] Considerar adicionar um campo de resposta indicando o dono do arquivo (\`ava_id\` ou \`ava.username\`) para facilitar a auditoria

_Levantado durante a revisão do `#151` por `@zoedsoupe`$PR_URL_" \
  --field labels='["engineering"]' \
  --field assignees='["zoedsoupe"]' \
  --jq '"Created: #\(.number) — \(.title)\n\(.html_url)"'

echo ""

# Issue 3: Purge em batch
gh api repos/taina-labs/taina/issues \
  --method POST \
  --field title="[Ybira] Otimizar purge_deleted_files/1 com deleção em batch no banco" \
  --field body="**Descrição**

A função \`purge_deleted_files/1\` hoje percorre os arquivos expirados um a um, abrindo uma transação individual no banco para cada registro deletado. Para uma comunidade com uma lixeira grande (ex.: 500–1.000 arquivos acumulados em 30 dias), isso significa 500–1.000 transações sequenciais numa única execução do worker — o que pode ser lento num hardware modesto como um Raspberry Pi 5.

A remoção física do disco (\`File.rm/1\`) continuará sendo individual (não dá para fazer em batch), mas a parte de banco e a atualização de cota podem ser feitas de forma muito mais eficiente com um \`Repo.delete_all\` agrupado.

**Tarefas**

- [ ] Refatorar \`purge_deleted_files/1\` para usar \`Repo.delete_all\` com \`select: f\` para recuperar os registros deletados de uma vez
- [ ] Calcular o ajuste de cota por tekoa em batch (agrupar por \`tekoa_id\` e somar \`file_size_bytes\`)
- [ ] Manter o \`Enum.each\` apenas para a remoção física dos arquivos em disco
- [ ] Atualizar o teste em \`PurgeTrashTest\` para cobrir um cenário com múltiplos arquivos de tekoас diferentes
- [ ] Medir (mesmo que informalmente) a diferença de tempo com e sem o batch num volume maior de registros

_Levantado durante a revisão do `#151` por `@zoedsoupe`$PR_URL_" \
  --field labels='["engineering"]' \
  --field assignees='["zoedsoupe"]' \
  --jq '"Created: #\(.number) — \(.title)\n\(.html_url)"'

echo ""

# Issue 4: EBML/Matroska ambiguidade no MimeDetector
gh api repos/taina-labs/taina/issues \
  --method POST \
  --field title="[Ybira] MimeDetector classifica arquivos .mkv como video/webm (EBML ambiguidade)" \
  --field body="**Descrição**

O \`Taina.Ybira.MimeDetector\` identifica o magic byte \`0x1A 0x45 0xDF 0xA3\` como \`video/webm\`. Esse magic byte é, na verdade, o cabeçalho **EBML genérico**, usado tanto pelo WebM quanto pelo **Matroska (.mkv)**. O resultado: arquivos \`.mkv\` são retornados com MIME type \`video/webm\`.

Para o MVP isso não é um problema de segurança (ambos os formatos estão na allowlist e são vídeos legítimos), mas pode causar confusão na interface: o usuário envia um \`.mkv\` e o sistema registra \`video/webm\`. Se futuramente a interface exibir o tipo do arquivo ou tomar decisões baseadas no MIME, isso pode gerar comportamentos inesperados.

A distinção entre WebM e Matroska exige ler o campo \`DocType\` dentro do header EBML (que vem logo após os primeiros bytes), o que implica ler um pouco mais do arquivo.

**Tarefas**

- [ ] Avaliar se a distinção WebM × Matroska é relevante para os casos de uso da plataforma no MVP
- [ ] Se sim: implementar leitura do campo \`DocType\` EBML para distinguir \`webm\`\`video/webm\` de \`matroska\`\`video/x-matroska\`
- [ ] Adicionar teste no \`MimeDetectorTest\` cobrindo um arquivo com header EBML Matroska
- [ ] Atualizar a allowlist se \`video/x-matroska\` for aceito

_Levantado durante a revisão do `#151` por `@zoedsoupe`$PR_URL_" \
  --field labels='["engineering"]' \
  --field assignees='["zoedsoupe"]' \
  --jq '"Created: #\(.number) — \(.title)\n\(.html_url)"'

echo ""

# Issue 5: `@doc` em Scope.for_ava/1
gh api repos/taina-labs/taina/issues \
  --method POST \
  --field title="[Maraca] Documentar exigência de preload em Scope.for_ava/1" \
  --field body="**Descrição**

A função \`Scope.for_ava/1\` exige que a struct \`Ava\` recebida tenha a associação \`tekoa\` **já precarregada**. Sem isso, a função levanta um \`FunctionClauseError\` em tempo de execução — um erro que pode ser difícil de rastrear, especialmente para quem está adicionando um novo call site pela primeira vez.

Hoje só existe um call site (\`FileController\`), onde \`get_session_user/1\` já garante o preload. Mas conforme a plataforma cresce e novos contextos chamam \`Scope.for_ava/1\`, a ausência de documentação explícita sobre esse requisito vira uma armadilha silenciosa.

**Tarefas**

- [ ] Adicionar \`@doc\` em \`Scope.for_ava/1\` documentando explicitamente que \`ava.tekoa\` precisa estar precarregada
- [ ] Incluir exemplo de uso correto (com \`Repo.preload\`) na docstring
- [ ] Considerar adicionar um \`@spec\` mais preciso ou uma guard clause que dê uma mensagem de erro mais clara quando o preload estiver faltando

_Levantado durante a revisão do `#151` por `@zoedsoupe`$PR_URL_" \
  --field labels='["documentation"]' \
  --field assignees='["zoedsoupe"]' \
  --jq '"Created: #\(.number) — \(.title)\n\(.html_url)"'

Length of output: 10199


🏁 Script executed:

#!/bin/bash
PR_URL="https://github.com/taina-labs/taina/pull/151"

# Issue 1: Permission.changeset granted_by_id auditoria
echo '{
  "title": "[Maraca] Trilha de auditoria: garantir que granted_by_id nunca seja nil em Permission",
  "labels": ["engineering"],
  "assignees": ["zoedsoupe"],
  "body": "**Descrição**\n\nAtualmente, o `Permission.changeset/2` faz `cast` e `validate_required` apenas para `:resource_id`, `:resource_type`, `:action` e `:ava_id`. O campo `granted_by_id` — que representa **quem concedeu a permissão** — é injetado externamente via `put_change/3` por quem chama o changeset.\n\nIsso funciona nos caminhos conhecidos (`insert_grant` e `approve_access_request`), mas deixa uma brecha silenciosa: qualquer novo caminho que chame `Permission.changeset/2` diretamente pode persistir uma permissão sem `granted_by_id`, comprometendo a trilha de auditoria sem levantar nenhum erro.\n\nNum sistema orientado à soberania e transparência das comunidades, saber **quem** concedeu uma permissão é tão importante quanto saber **o que** foi concedido.\n\n**Tarefas**\n\n- [ ] Criar um `grant_changeset/2` específico que inclua `granted_by_id` e `tekoa_id` no `cast` e no `validate_required`\n- [ ] Migrar `insert_grant` e `approve_access_request` para usar `grant_changeset/2`\n- [ ] Adicionar teste cobrindo a rejeição de uma permissão sem `granted_by_id`\n- [ ] Revisar se há outros call sites que chamam `Permission.changeset` diretamente\n\n_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_"
}' | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"✅ #\(.number) — \(.title)\n   \(.html_url)"'

echo ""

# Issue 2: list_trash/2 visibilidade admin
echo '{
  "title": "[Ybira] Admins não conseguem ver a lixeira de outros membros da comunidade",
  "labels": ["engineering"],
  "assignees": ["zoedsoupe"],
  "body": "**Descrição**\n\nA função `list_trash/2` filtra os arquivos deletados pelo `ava_id` do scope, ou seja, cada usuário enxerga **apenas sua própria lixeira**. Isso é o comportamento correto para membros comuns, mas deixa o admin sem visibilidade sobre os arquivos deletados de toda a tekoa.\n\nO problema prático: se a cota de armazenamento de uma comunidade está alta e o owner ou admin quer entender o motivo, não consegue auditar quais arquivos estão na lixeira ocupando espaço (pois os bytes não são devolvidos no soft delete — só no purge). Isso torna a gestão de cota opaca justamente para quem tem responsabilidade sobre ela.\n\n**Tarefas**\n\n- [ ] Decidir e documentar no `@callback` do `Taina.Ybira.Behaviour` se admin pode listar toda a lixeira da tekoa\n- [ ] Se sim: ajustar `list_trash/2` para que quando `scope.ava.role == :admin`, a query não filtre por `ava_id`\n- [ ] Adicionar teste cobrindo o cenário de admin listando lixeira de outro membro\n- [ ] Considerar adicionar um campo de resposta indicando o dono do arquivo (`ava_id` ou `ava.username`) para facilitar a auditoria\n\n_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_"
}' | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"✅ #\(.number) — \(.title)\n   \(.html_url)"'

echo ""

# Issue 3: Purge em batch
echo '{
  "title": "[Ybira] Otimizar purge_deleted_files/1 com deleção em batch no banco",
  "labels": ["engineering"],
  "assignees": ["zoedsoupe"],
  "body": "**Descrição**\n\nA função `purge_deleted_files/1` hoje percorre os arquivos expirados um a um, abrindo uma transação individual no banco para cada registro deletado. Para uma comunidade com uma lixeira grande (ex.: 500–1.000 arquivos acumulados em 30 dias), isso significa 500–1.000 transações sequenciais numa única execução do worker — o que pode ser lento num hardware modesto como um Raspberry Pi 5.\n\nA remoção física do disco (`File.rm/1`) continuará sendo individual (não dá para fazer em batch), mas a parte do banco e a atualização de cota podem ser feitas de forma muito mais eficiente com um `Repo.delete_all` agrupado.\n\n**Tarefas**\n\n- [ ] Refatorar `purge_deleted_files/1` para usar `Repo.delete_all` com `select: f` para recuperar os registros deletados de uma vez\n- [ ] Calcular o ajuste de cota por tekoa em batch (agrupar por `tekoa_id` e somar `file_size_bytes`)\n- [ ] Manter o `Enum.each` apenas para a remoção física dos arquivos em disco\n- [ ] Atualizar o teste em `PurgeTrashTest` para cobrir um cenário com múltiplos arquivos de tekoас diferentes\n- [ ] Medir (mesmo que informalmente) a diferença de tempo com e sem o batch num volume maior de registros\n\n_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_"
}' | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"✅ #\(.number) — \(.title)\n   \(.html_url)"'

echo ""

# Issue 4: EBML/Matroska ambiguidade
echo '{
  "title": "[Ybira] MimeDetector classifica arquivos .mkv como video/webm (ambiguidade EBML)",
  "labels": ["engineering"],
  "assignees": ["zoedsoupe"],
  "body": "**Descrição**\n\nO `Taina.Ybira.MimeDetector` identifica o magic byte `0x1A 0x45 0xDF 0xA3` como `video/webm`. Esse magic byte é, na verdade, o cabeçalho **EBML genérico**, usado tanto pelo WebM quanto pelo **Matroska (.mkv)**. O resultado: arquivos `.mkv` são retornados com MIME type `video/webm`.\n\nPara o MVP isso não é um problema de segurança (ambos os formatos estão na allowlist e são vídeos legítimos), mas pode causar confusão na interface: o usuário envia um `.mkv` e o sistema registra `video/webm`. Se futuramente a interface exibir o tipo do arquivo ou tomar decisões baseadas no MIME, isso pode gerar comportamentos inesperados.\n\nA distinção entre WebM e Matroska exige ler o campo `DocType` dentro do header EBML (logo após os primeiros bytes), o que implica ler um pouco mais do arquivo.\n\n**Tarefas**\n\n- [ ] Avaliar se a distinção WebM × Matroska é relevante para os casos de uso da plataforma no MVP\n- [ ] Se sim: implementar leitura do campo `DocType` EBML para distinguir `webm` → `video/webm` de `matroska` → `video/x-matroska`\n- [ ] Adicionar teste no `MimeDetectorTest` cobrindo um arquivo com header EBML Matroska\n- [ ] Atualizar a allowlist se `video/x-matroska` for aceito\n\n_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_"
}' | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"✅ #\(.number) — \(.title)\n   \(.html_url)"'

echo ""

# Issue 5: `@doc` em Scope.for_ava/1
echo '{
  "title": "[Maraca] Documentar exigência de preload em Scope.for_ava/1",
  "labels": ["documentation"],
  "assignees": ["zoedsoupe"],
  "body": "**Descrição**\n\nA função `Scope.for_ava/1` exige que a struct `Ava` recebida tenha a associação `tekoa` **já precarregada**. Sem isso, a função levanta um `FunctionClauseError` em tempo de execução — um erro que pode ser difícil de rastrear, especialmente para quem está adicionando um novo call site pela primeira vez.\n\nHoje só existe um call site (`FileController`), onde `get_session_user/1` já garante o preload. Mas conforme a plataforma cresce e novos contextos chamam `Scope.for_ava/1`, a ausência de documentação explícita sobre esse requisito vira uma armadilha silenciosa.\n\n**Tarefas**\n\n- [ ] Adicionar `@doc` em `Scope.for_ava/1` documentando explicitamente que `ava.tekoa` precisa estar precarregada\n- [ ] Incluir exemplo de uso correto (com `Repo.preload`) na docstring\n- [ ] Considerar adicionar um `@spec` mais preciso ou uma guard clause que dê uma mensagem de erro mais clara quando o preload estiver faltando\n\n_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_"
}' | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"✅ #\(.number) — \(.title)\n   \(.html_url)"'

Length of output: 8852


🏁 Script executed:

#!/bin/bash

# Issue 2: list_trash/2 visibilidade admin
echo '{
  "title": "[Ybira] Admins não conseguem ver a lixeira de outros membros da comunidade",
  "labels": ["engineering"],
  "assignees": ["zoedsoupe"],
  "body": "**Descrição**\n\nA função `list_trash/2` filtra os arquivos deletados pelo `ava_id` do scope, ou seja, cada usuário enxerga **apenas sua própria lixeira**. Isso é o comportamento correto para membros comuns, mas deixa o admin sem visibilidade sobre os arquivos deletados de toda a tekoa.\n\nO problema prático: se a cota de armazenamento de uma comunidade está alta e o owner ou admin quer entender o motivo, não consegue auditar quais arquivos estão na lixeira ocupando espaço (pois os bytes não são devolvidos no soft delete — só no purge). Isso torna a gestão de cota opaca justamente para quem tem responsabilidade sobre ela.\n\n**Tarefas**\n\n- [ ] Decidir e documentar no `@callback` do `Taina.Ybira.Behaviour` se admin pode listar toda a lixeira da tekoa\n- [ ] Se sim: ajustar `list_trash/2` para que quando `scope.ava.role == :admin`, a query não filtre por `ava_id`\n- [ ] Adicionar teste cobrindo o cenário de admin listando lixeira de outro membro\n- [ ] Considerar adicionar um campo de resposta indicando o dono do arquivo (`ava_id` ou `ava.username`) para facilitar a auditoria\n\n_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_"
}' | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"✅ #\(.number) — \(.title)\n   \(.html_url)"'

Length of output: 1939


🏁 Script executed:

#!/bin/bash

gh issue create \
  --repo taina-labs/taina \
  --title "[Ybira] Admins não conseguem ver a lixeira de outros membros da comunidade" \
  --label "engineering" \
  --assignee "zoedsoupe" \
  --body "$(cat <<'EOF'
**Descrição**

A função `list_trash/2` filtra os arquivos deletados pelo `ava_id` do scope, ou seja, cada usuário enxerga **apenas sua própria lixeira**. Isso é o comportamento correto para membros comuns, mas deixa o admin sem visibilidade sobre os arquivos deletados de toda a tekoa.

O problema prático: se a cota de armazenamento de uma comunidade está alta e o owner ou admin quer entender o motivo, não consegue auditar quais arquivos estão na lixeira ocupando espaço (pois os bytes não são devolvidos no soft delete — só no purge). Isso torna a gestão de cota opaca justamente para quem tem responsabilidade sobre ela.

**Tarefas**

- [ ] Decidir e documentar no `@callback` do `Taina.Ybira.Behaviour` se admin pode listar toda a lixeira da tekoa
- [ ] Se sim: ajustar `list_trash/2` para que quando `scope.ava.role == :admin`, a query não filtre por `ava_id`
- [ ] Adicionar teste cobrindo o cenário de admin listando lixeira de outro membro
- [ ] Considerar adicionar um campo de resposta indicando o dono do arquivo (`ava_id` ou `ava.username`) para facilitar a auditoria

_Levantado durante a revisão do `#151` por `@zoedsoupe` — https://github.com/taina-labs/taina/pull/151_
EOF
)"

Length of output: 1778


🏁 Script executed:

#!/bin/bash

printf '{"title":"[Ybira] Admins nao conseguem ver a lixeira de outros membros da comunidade","labels":["engineering"],"assignees":["zoedsoupe"],"body":"**Descricao**\\n\\nA funcao `list_trash/2` filtra os arquivos deletados pelo `ava_id` do scope, ou seja, cada usuario enxerga **apenas sua propria lixeira**. Isso e o comportamento correto para membros comuns, mas deixa o admin sem visibilidade sobre os arquivos deletados de toda a tekoa.\\n\\nO problema pratico: se a cota de armazenamento de uma comunidade esta alta e o owner ou admin quer entender o motivo, nao consegue auditar quais arquivos estao na lixeira ocupando espaco (pois os bytes nao sao devolvidos no soft delete, so no purge). Isso torna a gestao de cota opaca justamente para quem tem responsabilidade sobre ela.\\n\\n**Tarefas**\\n\\n- [ ] Decidir e documentar no `@callback` do `Taina.Ybira.Behaviour` se admin pode listar toda a lixeira da tekoa\\n- [ ] Se sim: ajustar `list_trash/2` para que quando `scope.ava.role == :admin`, a query nao filtre por `ava_id`\\n- [ ] Adicionar teste cobrindo o cenario de admin listando lixeira de outro membro\\n- [ ] Considerar adicionar um campo de resposta indicando o dono do arquivo (`ava_id` ou `ava.username`) para facilitar a auditoria\\n\\n_Levantado durante a revisao do `#151` por `@zoedsoupe` - https://github.com/taina-labs/taina/pull/151_"}' \
  | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"#\(.number) \(.html_url)"'

Length of output: 1574


🏁 Script executed:

#!/bin/bash

# Issue 3: Purge em batch
printf '{"title":"[Ybira] Otimizar purge_deleted_files/1 com delecao em batch no banco","labels":["engineering"],"assignees":["zoedsoupe"],"body":"**Descricao**\\n\\nA funcao `purge_deleted_files/1` hoje percorre os arquivos expirados um a um, abrindo uma transacao individual no banco para cada registro deletado. Para uma comunidade com uma lixeira grande (ex.: 500-1.000 arquivos acumulados em 30 dias), isso significa 500-1.000 transacoes sequenciais numa unica execucao do worker, o que pode ser lento num hardware modesto como um Raspberry Pi 5.\\n\\nA remocao fisica do disco (`File.rm/1`) continuara sendo individual, mas a parte do banco e a atualizacao de cota podem ser feitas de forma muito mais eficiente com um `Repo.delete_all` agrupado.\\n\\n**Tarefas**\\n\\n- [ ] Refatorar `purge_deleted_files/1` para usar `Repo.delete_all` com `select: f` para recuperar os registros deletados de uma vez\\n- [ ] Calcular o ajuste de cota por tekoa em batch (agrupar por `tekoa_id` e somar `file_size_bytes`)\\n- [ ] Manter o `Enum.each` apenas para a remocao fisica dos arquivos em disco\\n- [ ] Atualizar o teste em `PurgeTrashTest` para cobrir um cenario com multiplos arquivos de tekoас diferentes\\n- [ ] Medir (mesmo que informalmente) a diferenca de tempo com e sem o batch num volume maior de registros\\n\\n_Levantado durante a revisao do `#151` por `@zoedsoupe` - https://github.com/taina-labs/taina/pull/151_"}' \
  | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"#\(.number) \(.html_url)"'

Length of output: 1658


🏁 Script executed:

#!/bin/bash

# Issue 4: EBML/Matroska ambiguidade
printf '{"title":"[Ybira] MimeDetector classifica arquivos .mkv como video/webm (ambiguidade EBML)","labels":["engineering"],"assignees":["zoedsoupe"],"body":"**Descricao**\\n\\nO `Taina.Ybira.MimeDetector` identifica o magic byte `0x1A 0x45 0xDF 0xA3` como `video/webm`. Esse magic byte e, na verdade, o cabecalho **EBML generico**, usado tanto pelo WebM quanto pelo **Matroska (.mkv)**. O resultado: arquivos `.mkv` sao retornados com MIME type `video/webm`.\\n\\nPara o MVP isso nao e um problema de seguranca (ambos os formatos estao na allowlist e sao videos legitimos), mas pode causar confusao na interface: o usuario envia um `.mkv` e o sistema registra `video/webm`. Se futuramente a interface exibir o tipo do arquivo ou tomar decisoes baseadas no MIME, isso pode gerar comportamentos inesperados.\\n\\nA distincao entre WebM e Matroska exige ler o campo `DocType` dentro do header EBML (logo apos os primeiros bytes), o que implica ler um pouco mais do arquivo.\\n\\n**Tarefas**\\n\\n- [ ] Avaliar se a distincao WebM x Matroska e relevante para os casos de uso da plataforma no MVP\\n- [ ] Se sim: implementar leitura do campo `DocType` EBML para distinguir `webm` em `video/webm` de `matroska` em `video/x-matroska`\\n- [ ] Adicionar teste no `MimeDetectorTest` cobrindo um arquivo com header EBML Matroska\\n- [ ] Atualizar a allowlist se `video/x-matroska` for aceito\\n\\n_Levantado durante a revisao do `#151` por `@zoedsoupe` - https://github.com/taina-labs/taina/pull/151_"}' \
  | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"#\(.number) \(.html_url)"'

Length of output: 1738


🏁 Script executed:

#!/bin/bash

# Issue 5: `@doc` em Scope.for_ava/1
printf '{"title":"[Maraca] Documentar exigencia de preload em Scope.for_ava/1","labels":["documentation"],"assignees":["zoedsoupe"],"body":"**Descricao**\\n\\nA funcao `Scope.for_ava/1` exige que a struct `Ava` recebida tenha a associacao `tekoa` **ja precarregada**. Sem isso, a funcao levanta um `FunctionClauseError` em tempo de execucao, um erro que pode ser dificil de rastrear, especialmente para quem esta adicionando um novo call site pela primeira vez.\\n\\nHoje so existe um call site (`FileController`), onde `get_session_user/1` ja garante o preload. Mas conforme a plataforma cresce e novos contextos chamam `Scope.for_ava/1`, a ausencia de documentacao explicita sobre esse requisito vira uma armadilha silenciosa.\\n\\n**Tarefas**\\n\\n- [ ] Adicionar `@doc` em `Scope.for_ava/1` documentando explicitamente que `ava.tekoa` precisa estar precarregada\\n- [ ] Incluir exemplo de uso correto (com `Repo.preload`) na docstring\\n- [ ] Considerar adicionar um `@spec` mais preciso ou uma guard clause que de uma mensagem de erro mais clara quando o preload estiver faltando\\n\\n_Levantado durante a revisao do `#151` por `@zoedsoupe` - https://github.com/taina-labs/taina/pull/151_"}' \
  | gh api repos/taina-labs/taina/issues --method POST --input - --jq '"#\(.number) \(.html_url)"'

Length of output: 1438



@zoedsoupe Tudo pronto! 🌱 Aqui estão as cinco issues abertas e atribuídas a você:

# Contexto Issue
#152 Maraca Trilha de auditoria: garantir que granted_by_id nunca seja nil em Permission
#153 Ybira Admins não conseguem ver a lixeira de outros membros da comunidade
#154 Ybira Otimizar purge_deleted_files/1 com deleção em batch no banco
#155 Ybira MimeDetector classifica arquivos .mkv como video/webm (ambiguidade EBML)
#156 Maraca Documentar exigência de preload em Scope.for_ava/1

Cada issue segue o template de desenvolvimento/documentação do projeto, com descrição contextualizada do problema, impacto prático e checklist de tarefas. Backlinkadas para o #151. 🌿

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant