feat(C231): ingerir o acervo de fotos do Flickr no storage privado

Collection privada archivePhoto (flickrId unique+index, metadados do Flickr +
EXIF, leitura pela comunicação, mutação admin, entrada no mapa S3) com migration
aditiva add_archive_photo; ingestão idempotente (transação + upload) e CLI
pnpm flickr:import (plano dry-run por padrão, --apply com intenção/TEQO_ENV/S3,
--verify inventário read-only, recibos JSON em data/flickr/reports). Guard de
alvo extraído de reel-ingest para scripts/lib/cli.mjs; downloadUrlToFile ganha
headers (User-Agent do Flickr).
This commit is contained in:
Francisco Solla 2026-09-26 04:13:27 -03:00
parent 37bb239d1d
commit aba3aa4816
29 changed files with 22928 additions and 85 deletions

View file

@ -98,3 +98,10 @@ FFMPEG_PATH=
# `pnpm worktree next` mirrors this key into the worktree `.env.local` when it
# is set here, so report builds run with the same source as the main checkout.
PORTAL_TRANSPARENCIA_API_KEY=
# C231 — Flickr API credentials for the photo-archive ingestion
# (`pnpm flickr:import`). FLICKR_USER_ID is the NSID of the account
# (depjorgesolla). Both live only in the env — never in the repo; the key is
# read by the CLI at run time and never stored in receipts.
FLICKR_API_KEY=
FLICKR_USER_ID=

3
.gitignore vendored
View file

@ -61,6 +61,7 @@ next-env.d.ts
/recordingMedia
/contentMedia
/internetSpeechMedia
/archivePhoto
# TSE open-data zip cache (pnpm db:seed:tse)
/data/tse/
@ -81,6 +82,8 @@ next-env.d.ts
/data/camara/
# C215 — web-speech ingestion batch cache + reports (pnpm falas-web:import)
/data/falas-web/
# C231 — Flickr photo-archive cache + receipts (pnpm flickr:import)
/data/flickr/
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
/data/solla-ceuci-salvador/

View file

@ -0,0 +1 @@
**C231 — acervo de fotos do Flickr: ingestão completa para o storage privado (2026-09-26):** as 6.577 fotos da conta própria `depjorgesolla` passam a ter casa no Teqo — collection privada `archivePhoto` (upload, grupo Comunicação) com `flickrId` unique+index como identidade e metadados do Flickr estruturados (`title`/`description`/`tags`/`takenAt`/`postedAt`/`albums`/`geo` + `exif` json, `sourceUrl`/`owner`/`license` de proveniência; `alt` derivado), leitura pelos papéis de comunicação e mutação só do admin do Payload, com entrada no mapa S3 do plugin (sem ela o original cairia em disco efêmero em produção). A migration `add_archive_photo` é aditiva e o snapshot que a acompanha **repara a base da cadeia**: o `.json` anterior (`20260924_123940…`) estava dessincronizado e a migration gerada re-criava objetos já existentes (`internet_speech_media`, `recording_rels/topics/scopes`, colunas de `speech`/`content_piece`/`recording`); a migration foi aparada para só o DDL do acervo e o snapshot completo (106 tabelas) volta a ser o base das próximas `migrate:create`. A ingestão é idempotente por `flickrId` (`src/utilities/flickr/archivePhotoIngest.ts`: re-checa, grava linha+upload numa transação e devolve `created`/`existing`/`failed` com motivo) e o CLI é `pnpm flickr:import`: **plano/dry-run por padrão**, `--apply` (exige `FLICKR_IMPORT_CONFIRM=1` fora de dev local, `TEQO_ENV` casando o nome exato do banco e as 4 `S3_*`), `--verify` (inventário read-only: contagem, bytes, cobertura por álbum, metadados e ficheiros faltantes), `--limit`/`--page`/`--out`, recibos JSON datados em `data/flickr/reports/`. O client (`scripts/lib/flickrApi.mjs`) usa `fetch` nativo com `User-Agent`, pacing ~1 req/s, retry 429/5xx, `stat=fail` nomeado, EXIF tolerante e fallback para o maior tamanho quando não há `url_o`; o plano/recibo puro (`scripts/lib/flickrPlan.mjs`) conta vídeos pulados e itens sem id, resolve o mapa foto→álbuns antes de qualquer escrita e classifica novas/já existiam/fallback/inválidas; `downloadUrlToFile` ganha `headers` (UA do Flickr); e o guard de alvo dos imports saiu de `reel-ingest.mjs` para `scripts/lib/cli.mjs` (`assertEnvironmentDatabaseTarget` + `TEQO_ENV_DATABASE_BY_ENV`, com o C195 delegando sem mudar contrato). Envs `FLICKR_API_KEY`/`FLICKR_USER_ID` documentadas no `.env.example` (só env, nunca repo) e runbook do homeserver com staging-primeiro. Testes: unit novos de mapeamento/geo (`archivePhoto.unit`), client Flickr, plano/parser (`flickrPlan.unit`) e guardas em subprocesso (`flickrImportCli.unit`), int `archivePhotoIngest` (criação+objeto, convergência sem duplicar, falha honesta sem linha, access por papel e mutação admin); `pnpm gate:fast` verde (unit 431 files) e int full 105 files. Canary/backfill ficam para quando a chave da API for provisionada (Q1). Impl plan: `docs/plans/acervo-fotos-flickr-ingestao-impl.md`.

View file

@ -1296,6 +1296,48 @@ reel no admin tira da biblioteca e para de servir os arquivos. Para remover de
vez, apagar o `reel` (e as `reelMedia` órfãs) no admin; re-ingerir recria a
entrada (as mesmas chaves de objeto são sobrescritas).
## C231 — ingestão do acervo de fotos do Flickr
O comando `pnpm flickr:import` traz as fotos da conta `depjorgesolla` para o
acervo privado (`archivePhoto`): listagem + metadados pela API oficial e o
original de cada foto no bucket. A identidade é o **id da foto no Flickr** —
reexecutar converge ("novas / já existiam / falharam com motivo") e nunca
duplica; vídeos do photostream são contados e pulados. Nada é publicado e nada
no Flickr é tocado.
Chave e conta vêm do env do stack (`FLICKR_API_KEY`, `FLICKR_USER_ID` — nunca no
repo). O dry-run é o padrão e não escreve:
```bash
# no homeserver:
ssh homeserver
cd ~/stack
# 1) plano (dry-run; lê a API e o banco, não baixa nem escreve):
docker compose --profile maintenance run --rm \
teqo-staging-migrate pnpm flickr:import --limit 5
# 2) escrita no staging (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e FLICKR_IMPORT_CONFIRM=1 \
teqo-staging-migrate pnpm flickr:import --apply --limit 5
# 3) validação no staging e só então produção (TEQO_ENV=production, serviço teqo-1313-migrate)
# 4) inventário a qualquer momento (read-only, sem Flickr):
docker compose --profile maintenance run --rm \
teqo-staging-migrate pnpm flickr:import --verify
```
Os recibos JSON ficam em `data/flickr/reports/` do container — monte um volume
(`-v /srv/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do
banco (`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4
`S3_*` o `--apply` recusa (o container gravaria em disco efêmero); e
`FLICKR_IMPORT_CONFIRM=1` é exigido em alvo não-local. A chave da API nunca
entra em recibo/log.
Rollback: a migration `add_archive_photo` é aditiva. O rollback funcional é
apagar as linhas do `archivePhoto` no admin (o objeto correspondente no bucket
pode ser removido à parte); reexecutar a ingestão recria as mesmas chaves de
objeto.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)

View file

@ -0,0 +1,189 @@
# Impl: C231 — Acervo de fotos do Flickr — ingestão completa
Status: aprovado
Atualizado em: 2026-09-26
Issue: #1366
Intenção: docs/plans/acervo-fotos-flickr-ingestao.md
Appetite restante: herdado (~1 dia eng; o backfill completo é processamento ~2–3 h, não eng) — cortes explícitos: sem UI, sem refresh de metadados, sem paralelismo de download, `--verify` só DB.
## Leitura da intenção
- **Outcome:** as 6.577 fotos originais de `depjorgesolla` + metadados (data, álbuns, título/descrição/tags, geotag, EXIF) vivem no storage privado do Teqo; reexecutar converge sem duplicar e o recibo diz "novas / já existiam / falharam com motivo"; nada é publicado e nada no Flickr é tocado.
- **O que NÃO negociar:** originais (não miniaturas); identidade = id da foto no Flickr; falha nomeada, nunca silenciosa; nenhum arquivo órfão (objeto só sobrevive a rollback com nome determinístico e se autocorrige); dry-run por padrão; escrever no bucket exige confirmação explícita; CLI nunca aponta para DB/bucket de produção fora do runbook; nunca `media` (leitura anônima); sem vídeo, sem segundo cadastro de pessoa, sem curadoria/publicação (C232/C233/C234).
- **O que reavaliar:** (a) "fora do mapa S3 das collections públicas" significa ENTRAR no mapa privado do plugin — collection nova ausente do mapa cai em disco efêmero em prod; (b) o download precisa de `User-Agent` próprio (o `downloadToFile` atual não envia) — extensão aditiva; (c) metadados por foto não exigem `getInfo`/`getAllContexts`: os `extras` do listing cobrem o aceite e `getSizes` vira fallback; (d) a execução de escrita acontece no homeserver (compose/manutenção), não na mesa, porque o guard recusa DB remoto.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph CLI["scripts/ingest-flickr-archive.mjs — dry-run por padrão"]
G["guardas: TEQO_ENV+DB exato, FLICKR_IMPORT_CONFIRM, S3 fora de teste"] --> L["listagem: people.getPhotos (extras) + álbuns (photosets)"]
L --> P{"find por flickrId"}
P -- "já existia" --> R["recibo: já existiam (sem re-download)"]
P -- "nova" --> M["getExif (tolerante) + original: url_o | getSizes maior"]
M --> D["download p/ tmp com UA e teto"]
D --> U["ingestArchivePhoto: transação create (upload + req)"]
U --> R
end
U --> S3[("archivePhoto no bucket privado (mapa S3)")]
U --> DB[("linha archivePhoto")]
R --> J["data/flickr/reports/*.json + stdout"]
DB --> V["--verify: inventário DB read-only"]
```
**Opções consideradas:** A) espelhar a esteira web-speech/reels — client `.mjs` puro + utilitário Payload transacional em `src/utilities/flickr/` + CLI fino com guardas reusados | B) tudo no script `.mjs` | C) tudo em `src/utilities/`, com o fetch dentro do utilitário e o script como casca
**Recomendação:** A — é o único desenho em que a escrita se testa de verdade em int (Payload real, `teqo_test`, disco local) com seams injetáveis, enquanto API/plano/recibo ficam em unit com `fetch` mockado; reusa `withPayloadTransaction`, `resolveS3StorageEnv`, `downloadToFile` e o padrão de collection de upload privado, sem twin.
**Rejeitadas:** B porque a ingestão Payload/S3 ficaria fora do alcance das suítes (só spawn da CLI); C porque o fetch dentro do utilitário misturaria rede com transação e dificultaria a canary (`--limit`) — aqui o volume de 6,5k pede API isolada, pacing e retomada.
### Decisões de engenharia
**1. Collection de destino**
- Opções: A) `archivePhoto`/`ArchivePhoto` (slug em `src/lib/archivePhoto.ts`, utilitários em `src/utilities/flickr/`, grupo admin `Comunicação`, leitura `communicator`+) | B) `flickrPhoto` | C) estender `media` com gate.
- Recomendação: A — é o acervo, e a origem é metadado (`sourceUrl`/`owner`), não identidade; C232 adiciona curadoria na MESMA collection (o plano C232 declara "shape fica no C231") e C233/C234 leem `archivePhoto` sem rename; a subpasta `src/utilities/flickr/` respeita o pin de top-level de `src/utilities/`.
- Rejeitadas: B porque amarra curadoria e publicação ao nome da fonte e um rename futuro custaria migração + todos os consumidores; C porque `media` é leitura anônima por contrato (site público) — acervo bruto ali vazaria tudo.
- Metadados estruturados × json: estruturado para o que C232/C233 filtram (`title`, `description`, `tags`, `takenAt`, `albums`, `geo`); `exif` fica `json` cru (matéria-prima, não consulta). Rejeitado: um `rawFlickr` jsonb único (nada consultável; o que faltar entra por migração do dono C232, e o Flickr segue vivo como fonte).
**2. Fronteira script × utilitário**
- Opções: A) `scripts/lib/flickrApi.mjs` (HTTP) + `scripts/lib/flickrPlan.mjs` (plano/recibo puros) + `src/utilities/flickr/archivePhotoIngest.ts` (Payload/S3 transacional) + `scripts/ingest-flickr-archive.mjs` (orquestração), com mapeamento puro em `src/lib/archivePhoto.ts` | B) tudo em `scripts/` | C) tudo em `src/utilities/flickr/`.
- Recomendação: A — unit testa client (fetch injetado) e mapeamento puro; int testa a escrita contra Payload real com arquivo local; a CLI fica spawn-pinada nos guardas. Os dois `.mjs` novos entram em `SCRIPTS_SPEC_PINNED` (o invariante de `ciSkipInvariants` recomputa o closure e falha se faltar).
- Rejeitadas: B e C porque perdem uma das duas pontas de teste (rede isolada / Payload real) ou misturam as camadas.
**3. Chave de idempotência + nome do arquivo**
- Opções: A) `flickrId` text unique+index; nome determinístico `flickr-<flickrId>.<ext>` + `overwriteExistingFiles: true`; find ANTES de metadados/download | B) create com unique e catch | C) nome/slug derivado do título.
- Recomendação: A — o find-first (dry-run e apply) evita re-download e re-chamada de API para o que já existe ("já existiam" honesto) e é o modo de retomada (reexecutar é o retry dos que falharam); o nome determinístico faz a reexecução sobrescrever a MESMA chave (órfão só em rollback, aceito e autocorrigido) e o unique é a rede de segurança contra corrida.
- Rejeitadas: B porque baixa e chama a API antes de saber que já existe, e usa exceção como fluxo; C porque instabilidade/colisões geram sufixos do Payload e quebram a idempotência do objeto.
- `ext` deriva da URL do original (`url_o`/`getSizes`); sem extensão confiável → `.jpg` documentado. Filename original do Flickr (`<id>_<secret>_o.jpg`) rejeitado (carrega secret e varia entre tamanhos).
**4. Fluxo da API do Flickr**
- Opções: A) `flickr.people.getPhotos` (per_page=500, paginado, extras `description,date_taken,date_upload,license,owner_name,tags,geo,media,url_o,path_alias`) para o acervo por foto; álbuns via `flickr.photosets.getList` + `photosets.getPhotos` (mapa foto→álbuns montado no plano, ANTES de escrever); `flickr.photos.getExif` por foto (tolerante); `flickr.photos.getSizes` só como fallback do original | B) `getInfo` + `getAllContexts` + `getSizes` por foto | C) OAuth do dono / scraping.
- Recomendação: A — o listing com extras cobre todo o aceite de metadados (1 listagem em vez de 3 chamadas por foto); `getExif` é a única chamada por foto e é "quando houver"; vídeos (`media=video`) são pulados e CONTADOS no recibo; falha de álbum aborta o plano antes de qualquer escrita (metadado incompleto não vira linha); `api_key` por query (conta própria, escopo público), `User-Agent` próprio, `AbortSignal.timeout`, retry com backoff só em transitório (erros Flickr `stat=fail` nomeados por código/mensagem), pacing sequencial ~1 req/s (limite documentado 3600/h → backfill de EXIF ~2 h) e `--limit`/`--page` para canary.
- Rejeitadas: B porque triplica chamadas e `getAllContexts` é 1 chamada por foto (~6,5k) só para álbuns; C porque scraping é proibido pelo padrão e OAuth é desnecessário para leitura pública da própria conta (nada se escreve no Flickr).
- Fallback nomeado do original: `url_o` → maior size de `getSizes` → falha `original` no recibo; a escolha (`url_o` | `largest`) viaja no recibo.
**5. Recibo/inventário**
- Opções: A) JSON datado em `data/flickr/reports/flickr-<stamp>.json` (gitignored, padrão `data/falas-web/reports/`) + stdout; `--verify` read-only inventaria o DB | B) `var/reports/` | C) só stdout.
- Recomendação: A — mesmo regime dos imports existentes (relatório datado + linhas humanas): `runAt`, modo, alvo (sem segredo), `apiTotal`, novas/já existiam/falharam com estágio+motivo, bytes, duração, contagem por álbum, lacunas de EXIF e escolhas de original; `--verify` conta linhas, bytes, por álbum e cobertura de metadados sem tocar no Flickr — é o "responde sozinho sem abrir o Flickr" do aceite.
- Rejeitadas: C porque o aceite pede conferência posterior; B porque `data/` é o diretório canônico de cache/relatórios de script (gitignore + hábito).
- HEAD por objeto/listagem do bucket fica fora: falha de upload já falha a transação e vira linha de recibo, e o guard de S3 protege prod; gatilho de revisita: 404 em C233 → `--verify-bucket`.
**6. Guardas**
- Opções: A) `--apply` exige `FLICKR_IMPORT_CONFIRM=1` + alvo exato por `TEQO_ENV` (`teqo_staging`/`teqo_1313`; `ALLOW_REMOTE_DB` recusado; em teste só `teqo*_test`) + S3 completo fora de teste; dry-run é o default | B) `assertWriteConfirm` + `assertLocalDatabase` (host/`NODE_ENV`) | C) os dois.
- Recomendação: A — é o único que separa staging de produção pelo nome do banco (o proxy socat reescreve o host e `NODE_ENV=production` vale para os dois) e recusa escrita remota de mesa, exatamente o "nunca fora do runbook". O guard já existe em `scripts/lib/reel-ingest.mjs`; extrair para o dono do esqueleto CLI (`scripts/lib/cli.mjs`) como `assertEnvironmentDatabaseTarget` + `TEQO_ENV_DATABASE_BY_ENV` e fazer `reel-ingest.mjs` delegar (export e comportamento intactos → o unit pin do C195 continua verde), em vez de um gêmeo para fotos.
- Rejeitadas: B porque não distingue staging/produção e admite `ALLOW_REMOTE_DB`; C porque duplica guardas com cerimônia (dois erros para o mesmo alvo).
- `FLICKR_API_KEY`/`FLICKR_USER_ID` (NSID) lidos direto de `process.env` com erro manual limpo (precedente `camaraFetch.mjs`), documentados no `.env.example`; nunca no repo. Chave ausente falha antes de qualquer chamada. Runbook: o do homeserver (`docs/ops/teqo-1313-deploy.md`, padrão `reels:ingest` no container de manutenção, com o env file do stack).
**7. Migration**
- Opções: A) `pnpm migrate:create add_archive_photo`; campos estruturados; `flickrId` unique+index; sem índices de busca extras | B) jsonb "raw" | C) sem migration (`push`).
- Recomendação: A — `push: false` em todo ambiente; os índices de busca de C232/C233 são decisão do dono delas (migração aditiva barata); o único índice agora é o da identidade.
- Rejeitadas: C viola o contrato do repo (prod aplica só migração commitada); B adiada pela decisão 1.
- A mudança é área de risco alto no CI (`src/collections/`, `src/migrations/`) → o PR roda o conjunto curado de e2e (OPS86); registrar como risco, não como parada.
**8. Testes**
- Opções: A) unit (client com fetch injetado, mapeamento puro, spawn da CLI nos guardas) + int (ingestão real em Payload `teqo_test`, storage em disco, idempotência e access) | B) só int | C) e2e.
- Recomendação: A — a rede real do Flickr NUNCA entra na suíte (unit/CLI usam payloads canônicos e `fetchImpl`); int usa fixture JPEG real e arquivo local, cobrindo `created`/`existing`/falha e as negativas de access (anônimo e leader não leem; comunicação lê com `overrideAccess: false`).
- Rejeitadas: C porque não há UI nesta fatia; B porque guardas e client ficariam sem pin.
**9. Fases e tracer**
- Opções: A) tracer cedo — schema + ingestão de 1 foto (int) antes de qualquer lote; canary de 5 no alvo; backfill completo; `--verify` | B) implementar o CLI inteiro e só provar no lote completo | C) script-prova em disco e schema depois.
- Recomendação: A — o erro barato aparece no primeiro item (migração, mapa S3, nome determinístico); o lote de 6,5k só roda com o caminho já verde, canary primeiro.
- Rejeitadas: B porque o primeiro erro apareceria horas depois, no alvo; C porque adia as decisões caras (collection/mapa S3) para o fim e joga fora a prova.
### Componentes / mudanças
- **`ArchivePhoto`** (`src/collections/ArchivePhoto.ts`): collection de upload privado (`upload: true`), slug `ARCHIVE_PHOTO_SLUG` (`src/lib/archivePhoto.ts`, padrão `ContentMedia`/`InternetSpeechMedia`); labels pt-BR; `admin.group: 'Comunicação'`; campos: `flickrId` (text, required, unique+index, readOnly), `sourceUrl` (text), `owner` (text, NSID), `license` (text, id Flickr), `title` (text), `description` (textarea), `tags` (array `{ name }`), `takenAt` (text wall-clock `YYYY-MM-DD HH:MM:SS`, sem TZ — precedente `speechAt`), `postedAt` (text ISO UTC), `albums` (array `{ albumId, title }`), `geo` (group `latitude`/`longitude` number opcionais), `exif` (json), `alt` (text required; o ingest preenche do título ou de fallback determinístico; C232 refina). Reusa o padrão de mídia privada; NÃO usa `media`.
- **`archivePhoto`** (`src/lib/archivePhoto.ts`): slug, tipos do contrato (`ArchivePhotoImport`, `ArchivePhotoAlbum`), helpers puros `archivePhotoStorageFilename`, `archivePhotoAlt`, `archivePhotoSourceUrl`, `postedAtIso`, mapping do item de listing/álbuns/exif → `ArchivePhotoImport` (unit-pinado).
- **`archivePhotos` access** (`src/utilities/access/archivePhotos.ts` + re-export em `src/utilities/campaignAccess.ts`): `canReadArchivePhoto` (predicado próprio derivado de `canReadCommunicationCatalog` — precedente `reels`/`speeches`) e `payloadAdminOnly` para create/update/delete; fail-closed para anônimo e leader.
- **`archivePhotoIngest`** (`src/utilities/flickr/archivePhotoIngest.ts`): `findArchivePhotoByFlickrId` (depth 0, `overrideAccess: true` documentado — CLI sem sessão) e `ingestArchivePhoto(payload, record, { filePath })` — re-checa existência, `withPayloadTransaction` com `req`, `payload.create({ collection: 'archivePhoto', data, filePath, overwriteExistingFiles: true, overrideAccess: true })`, retorna `{ status: 'created'|'existing'|'failed', stage, error, bytes }`. Subpasta de domínio (top-level de `src/utilities/` é pinado).
- **`flickrApi`** (`scripts/lib/flickrApi.mjs`): `fetch` nativo (`AbortSignal.timeout` + `User-Agent` próprio, `fetchImpl` injetável); `people.getPhotos`, `photosets.getList`, `photosets.getPhotos`, `getExif`, `getSizes`; paginação até `total`; pacing sequencial configurável (default ~1 req/s) e retry com backoff só em transitório; erro Flickr nomeado (`stat != ok` → código/mensagem); vídeos retornados para o plano pular. Entra em `SCRIPTS_SPEC_PINNED`.
- **`flickrPlan`** (`scripts/lib/flickrPlan.mjs`): plano (remoto × `findArchivePhotoByFlickrId` → novo/existente), mapa de álbuns, agregação e formatação do recibo (linhas stdout + JSON), stamp próprio. Entra em `SCRIPTS_SPEC_PINNED`.
- **`ingest-flickr-archive`** (`scripts/ingest-flickr-archive.mjs` + script `pnpm flickr:import` no `package.json`): modos `plan` (default/dry-run), `--apply`, `--verify` (mutuamente exclusivos); `--limit`, `--page`, `--out`; guardas; loop por foto (find → getExif tolerante → original → download com teto → ingest → tmp limpo em `finally`); recibo; exit 1 só quando nada entrou ou falha fatal (falha por foto fica no recibo) e exit 1 em `--verify` com lacuna.
- **`cli.mjs`** (`scripts/lib/cli.mjs`, edição): `assertEnvironmentDatabaseTarget` + `TEQO_ENV_DATABASE_BY_ENV`, extraídos de `reel-ingest.mjs`, que passa a delegar (comportamento e exports atuais intactos; sem extrair o stamp — DRY abaixo de 3 call sites).
- **`downloadToFile`** (`src/utilities/media/downloadToFile.ts`, edição aditiva): opção `headers?: HeadersInit` para o UA do Flickr; default e chamadas atuais intactos.
- **Migration:** `add_archive_photo` (gerar com `pnpm migrate:create add_archive_photo` — DB local corrente antes; revisar o SQL; commitar `.ts`+`.json`+`index.ts`; `pnpm migrate` e `pnpm generate:types` locais).
- **`payload.config.ts`** (edição): `ArchivePhoto` na lista de collections + entrada `[ARCHIVE_PHOTO_SLUG]: true` no mapa S3 do plugin (sem ela, disco efêmero em prod).
- **`.env.example`** (edição): `FLICKR_API_KEY=`/`FLICKR_USER_ID=` (NSID de `depjorgesolla`) com comentário de runbook (só env, nunca repo). **`.gitignore`** (edição): `/archivePhoto` (storage local de dev/test) e `/data/flickr/` (cache/relatórios).
- **Entrega:** entrada em `docs/changelog/2026-09-26-c231.md` + este `docs/plans/acervo-fotos-flickr-ingestao-impl.md` no mesmo PR.
- **Access / Consent:** sem `Consent` novo (não há opt-in nem PII de titular nesta fatia; a remoção a pedido é C233) e sem `Contact`; leitura fail-closed pelo predicado do domínio.
- **UI:** Impeccable A — sem UI; nenhum shell/tabela; a ficha admin é gerada pelo Payload.
### Dados → forma
N/A — operação/ingestão; o recibo é arquivo JSON operacional (conferência), não superfície de apresentação. Nenhum agregado, KPI ou painel.
## Fases verificáveis
1. **Tracer / schema** — `src/lib/archivePhoto.ts` (helpers puros + mapper), `ArchivePhoto`, access, migration `add_archive_photo`, mapa S3, `.gitignore`, tipos; int spec insere 1 foto (fixture JPEG + metadados canônicos) via `ingestArchivePhoto` → linha + objeto; unit dos helpers/mapeamento. Verificação: `pnpm migrate`, `pnpm generate:types`, spec int verde. Quota: ~⅓ do appetite.
2. **Client + guardas + dry-run** — `flickrApi.mjs`, `flickrPlan.mjs`, extração do guard, CLI `plan`/`--help`; unit de client/plano e spawn dos guardas (produção sem confirm/S3/TEQO_ENV → recusa antes da rede); `SCRIPTS_SPEC_PINNED` +2. Canary manual com a chave (Q1): `pnpm flickr:import --limit 5` imprime plano e grava recibo sem escrever. Quota: ~⅓.
3. **Apply + recibo + verify** — `--apply` transacional, recibo, `--verify`; int de idempotência/access; canary `FLICKR_IMPORT_CONFIRM=1 pnpm flickr:import --apply --limit 5` no alvo; reexecução mostra "já existiam" e não duplica; backfill completo no homeserver (runbook) e `--verify` (contagem + bytes + álbuns). Quota: ~⅓.
4. **Gates** — `pnpm gate:fast`; `pnpm exec knip`; `pnpm check:cycles`; e2e da superfície (não há UI nova; o CI curado roda por `src/collections/`); `pnpm push` (gate:push com docs-guards).
## Rabbit holes / Não escopo (engenharia)
- Vídeos do photostream: pulados e CONTADOS no recibo (não ingeridos, não falham o run).
- Backfill de metadados em linhas já existentes (`--refresh-metadata`): fora; lacuna de EXIF vira linha nomeada no recibo; gatilho de revisita: C232 precisar de EXIF/GPS para a ficha.
- Paralelismo de download / pool de workers: fora; se o backfill completo for lento demais na prática, reabrir com pool pequeno (metadata continua sequencial).
- Checkpoint próprio em arquivo: o DB é o checkpoint (idempotência por `flickrId`); nada de estado paralelo.
- `--verify` com HEAD por objeto / listagem do bucket: fora; gatilho: 404 em C233.
- Coleção de álbuns/capítulos e renditions (thumbnails via sharp): C233.
- Campos de curadoria/estado de publicação/tags de IA: C232 (mesma collection, migração aditiva).
- `getInfo`/`getAllContexts` por foto, segunda collection doc+media, reuso da `media`: rejeitados nas decisões 4 e 1.
- UI/lista/filtros do acervo, publicação, sitemap, selfie: C232/C233/C234.
- Escrever/editar/apagar qualquer coisa no Flickr (incl. OAuth write): nunca.
## Riscos e mitigação
- **Collection nova fora do mapa S3 (disco efêmero em prod):** entrada explícita no mapa no mesmo PR + guard de S3 no `--apply` fora de teste; revisão do diff. (O int não cobre S3 — `.env.test` usa disco.)
- **Migração em área de risco alto do CI:** SQL revisado, `pnpm migrate` local antes do push, int verde; o PR roda o e2e curado por tocar `src/collections/`/`src/migrations/`.
- **Rate limit/instabilidade da API (3600/h):** pacing ~1 req/s + backoff + retry transitório; auth/permissão falham nomeado e imediato; `--limit`/`--page` para canary; `apiTotal` do listing × coletado no recibo (foto sumida da listagem não passa em silêncio).
- **Original indisponível (`url_o` ausente ou restrito):** fallback `getSizes` maior; se ambos falharem, falha `original` nomeada (sem linha, sem objeto).
- **Capacidade do bucket (Q3):** estimativa pelo canary (bytes médios × 6,5k) antes do lote; contingência por álbum/lotes do plano de intenção se apertar.
- **Run interrompido no meio:** reexecução converge pelo `flickrId` (existentes pulados sem re-download); recibo por execução.
- **Órfão de objeto em rollback:** aceito (bytes fora da transação); nome determinístico + `overwriteExistingFiles` se autocorrige na reexecução; nenhum órfão de linha.
- **Download sem UA (403 do CDN):** extensão aditiva do `downloadToFile` com UA do Flickr, sem mudar o comportamento atual.
- **Egress do homeserver:** confirmar no runbook a saída do container de manutenção para `api.flickr.com` e `live.staticflickr.com` (allowlist/proxy, se houver).
- **`SCRIPTS_SPEC_PINNED` desatualizado:** os dois `.mjs` novos entram no mesmo PR; o invariante falha se faltar (prevenção, não surpresa).
- **Chave de API ainda não provisionada (Q1):** fases 1–2 e toda a suíte fecham sem ela; canary/backfill ficam bloqueados até a chave chegar ao env do alvo (dependência de produto, sem gambiarra no repo).
## Já resolvido no simplify (não reabrir)
- Geo de meia-coordenada (`archivePhotoGeoFrom`) — `numberOrNull` por eixo + casos unit.
- `planArchiveEntries`: existente checado primeiro (nunca re-baixa), mapper como juiz do `url_o` (sem predicado gêmeo).
- 429 retentável no client; falha de API nomeada por estágio (`withStage`); `getLargestSize` ignora dims inválidas.
- `listArchivePhotos` no owner (`src/utilities/flickr/`), `parseArchiveCliArgs` no lib pinado, guard S3 spawn-pinado.
- `licenseOrNull`, bytes baixados sempre somados, `skippedAlbums` no recibo, `--out` por segmentos, `formatArchiveBytes` único, `ARCHIVE_PHOTO_SLUG` no mapa S3, JSDoc transacional do ingest, `main` com `applyEntries`/`reportBase`.
## Adiado com gatilho
- **`--limit` não corta a varredura de álbuns (S2).** O canary capa o processamento, mas `collectArchiveAlbums` percorre todos os álbuns/páginas antes do limite (documentado no `--help`; a decisão 4 exige o mapa foto→álbuns antes da escrita). Gatilho: canary/backfill lento na prática ou pressão de rate limit — reabrir junto do paralelismo de download já deferido (resolver o mapa só para os `flickrId` listados ou cortar a paginação do álbum cedo). Sem ação agora.
## Explicitamente fora
- **Factory dos predicados de leitura da Central (S1).** `canReadArchivePhoto` segue o padrão intencional de predicado próprio por superfície (o comentário "never an alias" em `contentPieces.ts`; cada vertical pode ampliar seu gate sem conceder escrita alheia). Reabrir só se uma ampliação de read precisar valer para todas as verticais de uma vez.
- **Tipagem do bag do recibo (S3)** e **`--out` absoluto (S4).** Doc-only/paridade com o irmão `import-web-speeches.mjs`; tipar se o formatter for tocado de novo; o recibo é arquivo operacional do operador.
- **Drift pré-existente `activity.tags`.** Coluna sobra no banco (a config não a declara; o snapshot de base também não) e é invisível ao diff — fora do escopo. O reparo do snapshot desta entrega (106 tabelas como novo base) elimina a re-geração de DDL alheia nas próximas `migrate:create`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: originais + metadados no S3 privado; identidade por `flickrId`; novas/já existiam/falharam com motivo; `--verify` responde sem o Flickr; dry-run default; confirmação explícita; nada publicado; nada tocado no Flickr.
- [ ] Invariantes AGENTS/engineering-standards: toda collection com `access` explícito; escrita multi-objeto em transação com `req`; sem cadastro paralelo a `Contact`; `push: false` + migração commitada (`.ts`+`.json`+`index.ts`); mapa S3 privado; identificadores em inglês/copy pt-BR; sem segredo no repo (chave só no env); importMap não commitado.
- [ ] Testes de domínio: unit (client com fetch mockado, mapeamento puro, guardas por spawn) e int (Payload real em `teqo_test`: create/existing/falha; access negado a anônimo/leader e permitido à comunicação); fixtures limpas; rede real do Flickr ausente da suíte.
- [ ] Entrega inclui `docs/plans/acervo-fotos-flickr-ingestao-impl.md` + entrada de changelog; `SCRIPTS_SPEC_PINNED` atualizado.
## Self-score (decision-quality)
1. Decisões caras têm rejeitadas? **Sim** — collection, fronteira, chave/nome, fluxo da API, recibo, guardas, migração, testes e fases, cada uma com A|B|C.
2. Cabe no appetite (~1 dia)? **Sim** — cortes explícitos (sem UI, sem refresh de metadados, sem paralelismo, verify DB-only); o backfill longo é processamento, não eng.
3. Rabbit holes nomeados? **Sim** — vídeo, refresh de metadados, paralelismo, checkpoint, verify de bucket, álbuns/renditions, curadoria.
4. Depth check: reusa owners existentes? **Sim** — `withPayloadTransaction`, `downloadToFile` (extensão aditiva), guard extraído de `reel-ingest` para `cli.mjs` (delegação), `resolveS3StorageEnv`, padrão de upload privado, find por chave natural, relatório datado; nada de segundo engine/pessoa/mídia.
5. Intenção permanece satisfeita sem reescrever o outcome? **Sim** — a engenharia fecha forma (schema, fronteira, guardas), não o aceite.
**Score: 5/5.**

View file

@ -34,6 +34,7 @@
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
"media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs",
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
"flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs",
"db:check:push-chain": "node scripts/check-push-chain.mjs",

View file

@ -0,0 +1,392 @@
/**
* C231 — ingests the Flickr photo archive (conta `depjorgesolla`) into the
* private Teqo storage: listing + metadata from the official API, originals
* downloaded once and one `archivePhoto` row + object per photo, keyed by the
* Flickr id. Re-running converges ("novas / já existiam / falharam com
* motivo"), the receipt lands under `data/flickr/reports/`, and NOTHING in the
* Flickr account is ever touched.
*
* Modes:
* pnpm flickr:import dry-run (default) — lists and maps the
* plan; no download, no write
* pnpm flickr:import --apply downloads + creates; requires
* FLICKR_IMPORT_CONFIRM=1 outside local
* dev and the complete S3_* envs
* pnpm flickr:import --verify read-only inventory of the stored
* archive (no Flickr, no bucket)
*
* Options: --limit <n> (canary), --page <n> (listing start page), --out <dir>
* (receipts, default `data/flickr`), --help. `--limit` caps the photos
* processed; the album walk still covers every album so a canary never loses
* memberships.
*
* Guards: --apply refuses a non-local/production target without the intent
* flag (C155) and demands the declared TEQO_ENV matching the exact database
* name (C195/C231); a remote target also demands the complete S3_* set. The
* API key lives in FLICKR_API_KEY (env only, never the repo) and the account
* NSID in FLICKR_USER_ID.
*
* Runbook: on the homeserver, inside the compose maintenance service with the
* stack env file — see docs/ops/teqo-1313-deploy.md.
*/
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { getPayload } from 'payload'
import {
archivePhotoImportFromFlickr,
archivePhotoStorageFilename,
} from '../src/lib/archivePhoto.ts'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
mirroredMediaRequired,
} from './lib/cli.mjs'
import { FLICKR_USER_AGENT, createFlickrClient } from './lib/flickrApi.mjs'
import {
ARCHIVE_PHOTO_DEFAULT_OUT_DIR,
archivePhotoReportStamp,
collectArchiveAlbums,
collectArchiveListing,
formatArchiveBytes,
formatArchiveInventory,
formatArchiveReport,
parseArchiveCliArgs,
planArchiveEntries,
summarizeArchiveInventory,
summarizeArchivePlan,
summarizeArchiveResults,
} from './lib/flickrPlan.mjs'
loadCliEnv()
const die = dieWithLabel('flickr:import')
const WRITE_CONFIRM_FLAG = 'FLICKR_IMPORT_CONFIRM'
/** A photo original is MBs; the guard only catches a stalled/runaway server. */
const PHOTO_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
const PHOTO_DOWNLOAD_TIMEOUT_MS = 2 * 60_000
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm flickr:import [opções]
Ingere o acervo de fotos do Flickr da conta própria (FLICKR_USER_ID, NSID) no
storage privado do Teqo. A identidade é o id da foto no Flickr: reexecutar não
duplica e converge; toda falha sai nomeada no recibo (novas / já existiam /
falharam com motivo). Nada é publicado e nada no Flickr é tocado.
Modos (mutuamente exclusivos; o default é o plano):
(sem flag) plano/dry-run — lista, mapeia e reporta; sem download nem escrita
--apply baixa os originais e cria as linhas/objetos no alvo
--verify inventário read-only do que já está no acervo (sem Flickr)
Opções:
--limit <n> processa no máximo n fotos (a varredura de álbuns continua completa)
--page <n> começa a listagem na página n (retomada)
--out <dir> diretório dos recibos (default ${ARCHIVE_PHOTO_DEFAULT_OUT_DIR})
--help esta ajuda
Ambiente:
FLICKR_API_KEY chave da API do Flickr (nunca no repo)
FLICKR_USER_ID NSID da conta (ex.: 12345678@N00)
DATABASE_URL alvo (obrigatório em todos os modos)
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1, TEQO_ENV=${envLines}
casando o banco exato e as quatro envs S3_* (o original nunca vai para disco
efêmero). O dry-run e o --verify são read-only.
`
class StageError extends Error {
constructor(stage, message) {
super(message)
this.name = 'StageError'
this.stage = stage
}
}
/** Runs one step, labelling any failure with its stage (receipt honesty). */
const withStage = async (stage, run) => {
try {
return await run()
} catch (error) {
if (error instanceof StageError) throw error
throw new StageError(stage, error instanceof Error ? error.message : String(error))
}
}
const writeReport = async (options, report, suffix = '') => {
const stamp = archivePhotoReportStamp(report.runAt)
const reportPath = join(options.out, 'reports', `flickr-${stamp}${suffix}.json`)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, JSON.stringify(report, null, 2))
return reportPath
}
const reportBase = ({ runAt, mode, listing, albumsById, skippedAlbums }) => ({
runAt,
mode,
target: databaseTarget(),
apiTotal: listing.apiTotal,
collected: listing.items.length,
videos: listing.videos.length,
malformed: listing.malformed,
albums: albumsById.size,
skippedAlbums,
})
/**
* The write pass: per photo → exif (tolerant) → original (listing `url_o` or
* the largest-size fallback) → download with a byte ceiling → transactional
* ingest. Every failure is captured with its stage; the temp file is always
* removed after the attempt.
*/
const applyEntries = async ({ client, payload, entries, tempRoot, download, ingest }) => {
const results = []
for (const entry of entries) {
if (entry.status === 'existing') {
results.push({ flickrId: entry.flickrId, status: 'existing' })
console.log(`[flickr:import] já existia ${entry.flickrId}`)
continue
}
if (entry.status === 'invalid') {
results.push({
flickrId: entry.flickrId,
status: 'failed',
stage: 'metadata',
error: `metadado inválido (${entry.reason})`,
})
console.log(`[flickr:import] falha metadata ${entry.flickrId}: ${entry.reason}`)
continue
}
let filePath = null
try {
const exif = await withStage('metadata', () => client.getExif(entry.flickrId))
let fallbackOriginalUrl = null
if (!entry.record) {
const largest = await withStage('original', () => client.getLargestSize(entry.flickrId))
fallbackOriginalUrl = largest?.url ?? null
}
const mapped = archivePhotoImportFromFlickr({
photo: entry.photo,
albums: entry.albums,
exif,
fallbackOriginalUrl,
})
if (!mapped.ok) {
throw new StageError(
mapped.reason === 'original' ? 'original' : 'metadata',
`não foi possível mapear a foto (${mapped.reason})`,
)
}
const record = mapped.record
filePath = join(tempRoot, archivePhotoStorageFilename(record.flickrId, record.originalUrl))
await withStage('download', () =>
download({
url: record.originalUrl,
destinationPath: filePath,
headers: { 'User-Agent': FLICKR_USER_AGENT },
timeoutMs: PHOTO_DOWNLOAD_TIMEOUT_MS,
maxBytes: PHOTO_DOWNLOAD_MAX_BYTES,
}),
)
const bytes = (await stat(filePath)).size
const result = await ingest(payload, record, { filePath })
results.push({
flickrId: entry.flickrId,
status: result.status,
stage: result.stage ?? null,
error: result.error ?? null,
bytes,
originalKind: record.originalKind,
})
console.log(
`[flickr:import] ${result.status} ${entry.flickrId} (${formatArchiveBytes(bytes)}, ${record.originalKind})`,
)
} catch (error) {
const stage = error instanceof StageError ? error.stage : 'download'
const message = error instanceof Error ? error.message : String(error)
results.push({ flickrId: entry.flickrId, status: 'failed', stage, error: message })
console.log(`[flickr:import] falha ${stage} ${entry.flickrId}: ${message}`)
} finally {
if (filePath) await rm(filePath, { force: true }).catch(() => undefined)
}
}
return results
}
async function main() {
const options = parseArchiveCliArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const mode = options.apply ? 'apply' : options.verify ? 'verify' : 'plan'
const runAt = new Date().toISOString()
console.log(`[flickr:import] alvo: ${databaseTarget()} | modo: ${mode}`)
if (mode === 'apply') {
assertWriteConfirm({
label: 'flickr:import',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm flickr:import --apply',
})
const target = assertEnvironmentDatabaseTarget()
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
const storage = resolveS3StorageEnv(process.env)
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
die(
`escrita em produção/remoto (${target.environment}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o original iria para disco local efêmero.`,
)
}
}
if (mode !== 'verify') {
if (!process.env.FLICKR_API_KEY?.trim()) {
die('FLICKR_API_KEY ausente — configure a chave da API do Flickr (veja .env.example).')
}
if (!process.env.FLICKR_USER_ID?.trim()) {
die('FLICKR_USER_ID ausente — informe o NSID da conta (ex.: 12345678@N00).')
}
}
const config = (await import('../src/payload.config.ts')).default
const { downloadUrlToFile } = await import('../src/utilities/media/downloadToFile.ts')
const { findArchivePhotoByFlickrId, ingestArchivePhoto, listArchivePhotos } =
await import('../src/utilities/flickr/archivePhotoIngest.ts')
const payload = await getPayload({ config })
const startedAt = Date.now()
if (mode === 'verify') {
const rows = await listArchivePhotos(payload)
const inventory = summarizeArchiveInventory(rows)
const report = {
runAt,
mode,
target: databaseTarget(),
...inventory,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report, '-verify')
for (const line of formatArchiveInventory(inventory)) console.log(line)
console.log(`\n[flickr:import] recibo JSON: ${reportPath}`)
if (inventory.missingFilename.length > 0) {
die(
`${inventory.missingFilename.length} foto(s) sem arquivo armazenado — acervo inconsistente.`,
)
}
process.exit(0)
}
const userId = process.env.FLICKR_USER_ID.trim()
const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY })
console.log('[flickr:import] listando fotos e álbuns na API do Flickr...')
const listing = await collectArchiveListing({
client,
userId,
startPage: options.page,
limit: options.limit,
})
const { albumsById, albumsByPhotoId, skippedAlbums } = await collectArchiveAlbums({
client,
userId,
})
console.log(
`[flickr:import] listagem: ${listing.items.length} foto(s) de ${listing.apiTotal} na API · ${albumsById.size} álbum(ns)` +
(skippedAlbums.length > 0
? ` · ${skippedAlbums.length} álbum(ns) sem id/título pulados`
: ''),
)
const entries = await planArchiveEntries({
items: listing.items,
albumsByPhotoId,
findExisting: (flickrId) => findArchivePhotoByFlickrId(payload, flickrId),
})
const plan = summarizeArchivePlan(entries)
const base = reportBase({ runAt, mode, listing, albumsById, skippedAlbums })
if (mode === 'plan') {
const report = {
...base,
plan,
invalidEntries: entries
.filter((entry) => entry.status === 'invalid')
.map((entry) => ({ flickrId: entry.flickrId, reason: entry.reason })),
entries: entries.map((entry) => ({
flickrId: entry.flickrId,
status: entry.status,
reason: entry.reason ?? null,
albums: entry.albums.map((album) => album.albumId),
title: entry.record?.title ?? null,
takenAt: entry.record?.takenAt ?? null,
})),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatArchiveReport(report)) console.log(line)
console.log(`\n[flickr:import] recibo JSON: ${reportPath}`)
process.exit(0)
}
const tempRoot = await mkdtemp(join(tmpdir(), 'flickr-archive-'))
let results
try {
results = await applyEntries({
client,
payload,
entries,
tempRoot,
download: downloadUrlToFile,
ingest: ingestArchivePhoto,
})
} finally {
await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined)
}
const summary = summarizeArchiveResults(results)
const report = {
...base,
plan,
summary,
results: results.map((result) => ({
flickrId: result.flickrId,
status: result.status,
stage: result.stage ?? null,
error: result.error ?? null,
bytes: result.bytes ?? 0,
originalKind: result.originalKind ?? null,
})),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatArchiveReport(report)) console.log(line)
console.log(`\n[flickr:import] recibo JSON: ${reportPath}`)
if (summary.created + summary.existing === 0 && results.length > 0) {
die('nenhuma foto entrou no acervo — falhas listadas no recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -209,6 +209,97 @@ export const databaseTarget = () => {
return `${host}${new URL(url).pathname}`
}
/**
* Declared environment → exact database name. The name is the honest
* discriminator on the homeserver: the socat proxy rewrites the host to
* 127.0.0.1 and `NODE_ENV=production` is set in both env files, so the host
* check alone cannot tell staging from production (C195/C231).
*/
export const TEQO_ENV_DATABASE_BY_ENV = Object.freeze({
staging: 'teqo_staging',
production: 'teqo_1313',
})
/**
* Fail-closed write-target guard shared by the ingest CLIs (C195/C231).
* Outside a test run the declared `TEQO_ENV` (`staging`|`production`) is
* mandatory and must match the exact database name; inside vitest only a
* `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is always refused —
* an ingest never runs from a workstation against a remote database.
*
* @param {{
* databaseUrl?: string | null,
* teqoEnv?: string | null,
* allowRemoteDb?: boolean,
* isTest?: boolean,
* }} [options]
* @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }}
*/
export const assertEnvironmentDatabaseTarget = ({
databaseUrl = process.env.DATABASE_URL,
teqoEnv = process.env.TEQO_ENV,
allowRemoteDb = isRemoteDbOverrideSet(),
isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST),
} = {}) => {
if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') {
throw new Error(
'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.',
)
}
const targetDatabaseName = databaseName(databaseUrl)
let protocol = null
try {
protocol = new URL(String(databaseUrl ?? '')).protocol
} catch {
protocol = null
}
if (protocol !== 'postgresql:') {
throw new Error(
`protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — a ingestão só escreve num alvo PostgreSQL.`,
)
}
if (!isLocalDatabaseUrl(databaseUrl)) {
// Never echo the URL — it carries the DB password.
throw new Error(
`host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — a ingestão nunca escreve em alvo remoto.`,
)
}
if (allowRemoteDb) {
throw new Error(
'ALLOW_REMOTE_DB está setado — remova; a ingestão não precisa do override remoto.',
)
}
if (isTest) {
if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — a ingestão em teste só escreve em "teqo*_test".`,
)
}
return { environment: 'test', databaseName: targetDatabaseName }
}
if (teqoEnv !== 'staging' && teqoEnv !== 'production') {
throw new Error(
`TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`,
)
}
const expected = TEQO_ENV_DATABASE_BY_ENV[teqoEnv]
if (targetDatabaseName !== expected) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` +
'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.',
)
}
return { environment: teqoEnv, databaseName: targetDatabaseName }
}
/**
* C155/C215 write guard shared by the import CLIs: a non-local/production
* target (or the `ALLOW_REMOTE_DB` escape) refuses the run unless the command's

202
scripts/lib/flickrApi.mjs Normal file
View file

@ -0,0 +1,202 @@
/**
* C231 — thin Flickr API client for the photo-archive ingestion: plain `fetch`
* (injectable), a proper `User-Agent`, sequential pacing and retry with
* backoff for transient failures only. There is no SDK dependency: the account
* is read-only and the endpoints used here answer JSON.
*
* `stat=fail` answers (auth, permission, not-found) are terminal and surface
* as `FlickrApiError` with the Flickr code — the receipt names them. The API
* key travels in the query string (Flickr's own model for public reads) and
* never enters logs or receipts.
*/
const FLICKR_API_URL = 'https://api.flickr.com/services/rest/'
export const FLICKR_USER_AGENT = 'teqo-flickr-archive/1.0 (+https://jorgesolla1313.com.br)'
const FLICKR_LISTING_PAGE_SIZE = 500
/** Flickr documents 3,600 calls/hour — one call per second stays under it. */
const FLICKR_PACING_MS = 1000
/** One listing call carries every metadata field the archive needs. */
export const FLICKR_PHOTO_EXTRAS =
'description,date_taken,date_upload,license,owner_name,tags,geo,media,url_o,path_alias'
const FLICKR_TIMEOUT_MS = 30_000
const FLICKR_RETRY_ATTEMPTS = 4
const FLICKR_RETRY_BASE_DELAY_MS = 1_000
class FlickrApiError extends Error {
/**
* @param {string} message
* @param {{ code?: string | number | null, status?: number | null }} [options]
*/
constructor(message, { code = null, status = null } = {}) {
super(message)
this.name = 'FlickrApiError'
this.code = code
this.status = status
}
}
/** HTTP 429/5xx and thrown network/timeout errors retry; `stat=fail` never does. */
const isRetryable = (error) => {
if (error instanceof FlickrApiError) {
return error.status === 429 || (error.status !== null && error.status >= 500)
}
return true
}
/**
* @param {{
* apiKey?: string,
* fetchImpl?: typeof fetch,
* pacingMs?: number,
* retryAttempts?: number,
* retryBaseDelayMs?: number,
* timeoutMs?: number,
* sleep?: (ms: number) => Promise<void>,
* now?: () => number,
* }} [options]
*/
export const createFlickrClient = ({
apiKey,
fetchImpl = fetch,
pacingMs = FLICKR_PACING_MS,
retryAttempts = FLICKR_RETRY_ATTEMPTS,
retryBaseDelayMs = FLICKR_RETRY_BASE_DELAY_MS,
timeoutMs = FLICKR_TIMEOUT_MS,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now = () => Date.now(),
} = {}) => {
const key = typeof apiKey === 'string' ? apiKey.trim() : ''
if (key === '') {
throw new Error('FLICKR_API_KEY ausente — a ingestão exige a chave da API do Flickr.')
}
let lastCallAt = null
const pace = async () => {
if (lastCallAt !== null && pacingMs > 0) {
const wait = lastCallAt + pacingMs - now()
if (wait > 0) await sleep(wait)
}
lastCallAt = now()
}
/**
* One REST call with pacing + transient retry. Returns the parsed body with
* `stat: 'ok'`; anything else is a named refusal.
*/
const call = async (method, params = {}) => {
for (let attempt = 1; ; attempt += 1) {
await pace()
try {
const url = new URL(FLICKR_API_URL)
url.searchParams.set('method', method)
url.searchParams.set('api_key', key)
url.searchParams.set('format', 'json')
url.searchParams.set('nojsoncallback', '1')
for (const [name, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(name, String(value))
}
}
const response = await fetchImpl(url.toString(), {
headers: { 'User-Agent': FLICKR_USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) {
throw new FlickrApiError(`HTTP ${response.status}`, { status: response.status })
}
const body = await response.json()
if (body?.stat !== 'ok') {
throw new FlickrApiError(
`Flickr ${body?.code ?? '?'}: ${body?.message ?? 'erro desconhecido'}`,
{ code: body?.code ?? null },
)
}
return body
} catch (error) {
if (attempt >= retryAttempts || !isRetryable(error)) throw error
await sleep(retryBaseDelayMs * 2 ** (attempt - 1))
}
}
}
return {
call,
/** One page of the account photostream (`flickr.people.getPhotos`). */
listPhotosPage: async ({
userId,
page = 1,
perPage = FLICKR_LISTING_PAGE_SIZE,
extras = FLICKR_PHOTO_EXTRAS,
}) => {
const body = await call('flickr.people.getPhotos', {
user_id: userId,
page,
per_page: perPage,
extras,
})
return body.photos ?? { page: 1, pages: 1, per_page: perPage, total: 0, photo: [] }
},
/** One page of the account albums (`flickr.photosets.getList`). */
listPhotoSets: async ({ userId, page = 1, perPage = FLICKR_LISTING_PAGE_SIZE }) => {
const body = await call('flickr.photosets.getList', {
user_id: userId,
page,
per_page: perPage,
})
return body.photosets ?? { page: 1, pages: 1, per_page: perPage, total: 0, photoset: [] }
},
/** One page of an album's photos (`flickr.photosets.getPhotos`). */
listPhotoSetPage: async ({ userId, albumId, page = 1, perPage = FLICKR_LISTING_PAGE_SIZE }) => {
const body = await call('flickr.photosets.getPhotos', {
user_id: userId,
photoset_id: albumId,
page,
per_page: perPage,
})
return body.photoset ?? { page: 1, pages: 1, per_page: perPage, photo: [] }
},
/**
* `flickr.photos.getExif` — tolerant by contract: a photo without readable
* EXIF is not a failure (the receipt records the gap). HTTP/network errors
* still propagate.
*/
getExif: async (photoId) => {
try {
const body = await call('flickr.photos.getExif', { photo_id: photoId })
return body.photo?.exif ?? []
} catch (error) {
if (error instanceof FlickrApiError && error.status === null) return []
throw error
}
},
/**
* Largest available image from `flickr.photos.getSizes` — the documented
* fallback when the listing has no `url_o`. Sizes without usable
* dimensions are ignored (never a NaN winner); null when nothing usable is
* returned.
*/
getLargestSize: async (photoId) => {
const body = await call('flickr.photos.getSizes', { photo_id: photoId })
const sizes = Array.isArray(body.sizes?.size) ? body.sizes.size : []
let largest = null
let largestArea = -1
for (const size of sizes) {
if (size?.media !== 'photo' || typeof size.source !== 'string') continue
const area = Number(size.width) * Number(size.height)
if (!Number.isFinite(area) || area <= largestArea) continue
largest = size
largestArea = area
}
return largest ? { url: largest.source } : null
},
}
}

390
scripts/lib/flickrPlan.mjs Normal file
View file

@ -0,0 +1,390 @@
/**
* C231 — pure planning/reporting of the Flickr photo-archive ingestion:
* listing collection (pagination, videos, limit), the photo→albums map, the
* plan entries (new/existing/fallback/invalid), the run summaries and the
* human lines + JSON receipt. No I/O of its own: the Flickr client and the
* Payload lookups arrive as parameters, so the unit spec drives everything
* with fakes.
*/
import { archivePhotoImportFromFlickr } from '../../src/lib/archivePhoto.ts'
/**
* @typedef {import('../../src/lib/archivePhoto.ts').ArchivePhotoAlbum} ArchivePhotoAlbum
* @typedef {import('../../src/lib/archivePhoto.ts').ArchivePhotoImport} ArchivePhotoImport
*/
/**
* @typedef {object} FlickrPhotoItem
* @property {string | number} [id]
* @property {string} [media]
* @property {string} [url_o]
* @property {Record<string, unknown>} [key: string]
*
* The client is injected (real one in the CLI, fakes in the unit spec): any
* method answering a Promise; the call shapes are pinned by the CLI's usage.
*
* @typedef {Object<string, (options?: any) => Promise<any>>} FlickrClientLike
*/
const VIDEO_MEDIA = 'video'
const BYTES_PER_MIB = 1024 * 1024
const USAGE =
'uso: `pnpm flickr:import [--apply|--verify] [--limit <n>] [--page <n>] [--out <dir>]`'
export const ARCHIVE_PHOTO_DEFAULT_OUT_DIR = 'data/flickr'
export const archivePhotoReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
/**
* `pnpm flickr:import` argv parser — pure, so the unit spec pins it (the CLI
* owns the exit and the help text). The default mode is the plan/dry-run;
* errors carry the usage line.
*
* @param {string[]} [argv]
* @returns {{ apply: boolean, verify: boolean, limit: number | null, page: number, out: string, help: boolean }}
*/
export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
verify: false,
limit: null,
page: 1,
out: ARCHIVE_PHOTO_DEFAULT_OUT_DIR,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para ${arg} — ${USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--verify') options.verify = true
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--page') options.page = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
}
if (options.help) return options
if (options.apply && options.verify) {
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
}
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
throw new Error('--limit deve ser >= 1.')
}
if (!Number.isInteger(options.page) || options.page < 1) {
throw new Error('--page deve ser >= 1.')
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
/**
* Walks `flickr.people.getPhotos` pages until the listing ends or `limit`
* photos were collected. Videos are counted and skipped (own backlog; never
* ingested here), and an item without a usable id is named in `malformed`
* instead of vanishing.
*
* @param {{
* client: any,
* userId: string,
* startPage?: number,
* limit?: number | null,
* }} options
* @returns {Promise<{
* items: any[],
* videos: string[],
* malformed: Array<{ reason: string }>,
* apiTotal: number,
* pages: number,
* pagesFetched: number,
* }>}
*/
export const collectArchiveListing = async ({ client, userId, startPage = 1, limit = null }) => {
const items = []
const videos = []
const malformed = []
let apiTotal = 0
let pages = 1
let page = startPage
for (;;) {
const listing = await client.listPhotosPage({ userId, page })
pages = Math.max(1, Number(listing?.pages) || 1)
if (page === startPage) apiTotal = Number(listing?.total) || 0
const batch = Array.isArray(listing?.photo) ? listing.photo : []
for (const photo of batch) {
if (photo?.media === VIDEO_MEDIA) {
videos.push(String(photo.id ?? ''))
continue
}
const id = photo?.id === undefined || photo?.id === null ? null : String(photo.id).trim()
if (id === null || id === '') {
malformed.push({ reason: 'id' })
continue
}
items.push(photo)
if (limit !== null && items.length >= limit) {
return { items, videos, malformed, apiTotal, pages, pagesFetched: page - startPage + 1 }
}
}
if (page >= pages) break
page += 1
}
return { items, videos, malformed, apiTotal, pages, pagesFetched: page - startPage + 1 }
}
/**
* The whole photo→albums map, resolved BEFORE any write: every album of the
* account is listed, then each album's photos are walked. A photo in N albums
* stays one photo with N memberships (the ingestion never duplicates it).
*
* @param {{ client: FlickrClientLike, userId: string }} options
* @returns {Promise<{
* albumsById: Map<string, ArchivePhotoAlbum>,
* albumsByPhotoId: Map<string, ArchivePhotoAlbum[]>,
* skippedAlbums: Array<{ albumId: string, reason: 'id' | 'title' }>,
* }>}
*/
export const collectArchiveAlbums = async ({ client, userId }) => {
const albumsById = new Map()
const albumsByPhotoId = new Map()
const skippedAlbums = []
let page = 1
let pages = 1
do {
const listing = await client.listPhotoSets({ userId, page })
pages = Math.max(1, Number(listing?.pages) || 1)
for (const photoset of Array.isArray(listing?.photoset) ? listing.photoset : []) {
const albumId =
photoset?.id === undefined || photoset?.id === null ? '' : String(photoset.id).trim()
const titleValue = photoset?.title?._content ?? photoset?.title
const title = typeof titleValue === 'string' ? titleValue.trim() : ''
if (albumId === '' || title === '') {
skippedAlbums.push({ albumId, reason: albumId === '' ? 'id' : 'title' })
continue
}
if (!albumsById.has(albumId)) albumsById.set(albumId, { albumId, title })
}
page += 1
} while (page <= pages)
for (const album of albumsById.values()) {
let photoPage = 1
let photoPages = 1
do {
const listing = await client.listPhotoSetPage({
userId,
albumId: album.albumId,
page: photoPage,
})
photoPages = Math.max(1, Number(listing?.pages) || 1)
for (const photo of Array.isArray(listing?.photo) ? listing.photo : []) {
const photoId = photo?.id === undefined || photo?.id === null ? '' : String(photo.id).trim()
if (photoId === '') continue
const memberships = albumsByPhotoId.get(photoId) ?? []
if (!memberships.some((entry) => entry.albumId === album.albumId)) {
memberships.push(album)
}
albumsByPhotoId.set(photoId, memberships)
}
photoPage += 1
} while (photoPage <= photoPages)
}
return { albumsById, albumsByPhotoId, skippedAlbums }
}
/**
* Maps the collected listing into plan entries, checking each `flickrId`
* against what is already archived FIRST — an archived photo is never
* re-mapped nor re-downloaded, even one that needed the size fallback. For the
* rest, the mapper is the single judge of `url_o`: its absence is the named
* `fallback` status (the apply pass resolves the largest size), anything else
* is invalid with its own reason.
*
* @param {{
* items: FlickrPhotoItem[],
* albumsByPhotoId: Map<string, ArchivePhotoAlbum[]>,
* findExisting: (flickrId: string) => Promise<unknown>,
* }} options
*/
export const planArchiveEntries = async ({ items, albumsByPhotoId, findExisting }) => {
const entries = []
for (const photo of items) {
const flickrId = String(photo.id).trim()
const albums = albumsByPhotoId?.get(flickrId) ?? []
const existing = await findExisting(flickrId)
if (existing) {
entries.push({ flickrId, albums, photo, status: 'existing', existing })
continue
}
const mapped = archivePhotoImportFromFlickr({ photo, albums, exif: null })
if (mapped.ok) {
entries.push({ flickrId, albums, photo, status: 'new', record: mapped.record })
} else if (mapped.reason === 'original') {
entries.push({ flickrId, albums, photo, status: 'fallback' })
} else {
entries.push({ flickrId, albums, photo, status: 'invalid', reason: mapped.reason })
}
}
return entries
}
/** @param {Array<{ status: string }>} entries */
export const summarizeArchivePlan = (entries) => ({
new: entries.filter((entry) => entry.status === 'new').length,
existing: entries.filter((entry) => entry.status === 'existing').length,
fallback: entries.filter((entry) => entry.status === 'fallback').length,
invalid: entries.filter((entry) => entry.status === 'invalid').length,
})
/**
* @param {Array<{ status: string, bytes?: number | null, flickrId: string, stage?: string | null, error?: string | null }>} results
*/
export const summarizeArchiveResults = (results) => ({
created: results.filter((result) => result.status === 'created').length,
existing: results.filter((result) => result.status === 'existing').length,
failed: results.filter((result) => result.status === 'failed').length,
bytes: results.reduce((total, result) => total + (Number(result.bytes) || 0), 0),
failures: results
.filter((result) => result.status === 'failed')
.map((result) => ({
flickrId: result.flickrId,
stage: result.stage ?? null,
error: result.error ?? null,
})),
})
/**
* The `--verify` inventory over the stored rows (read-only, no Flickr, no
* bucket): counts, bytes, per-album coverage, metadata gaps and rows without
* a stored filename (the one broken state this check fails on).
*
* @param {any[]} rows
*/
export const summarizeArchiveInventory = (rows) => {
const albums = new Map()
const missingFilename = []
let bytes = 0
let withTakenAt = 0
let withGeo = 0
let withExif = 0
let withTags = 0
let withAlbums = 0
for (const row of rows) {
bytes += Number(row?.filesize) || 0
if (row?.takenAt) withTakenAt += 1
if (row?.geo?.latitude !== undefined && row?.geo?.latitude !== null) withGeo += 1
if (Array.isArray(row?.exif) && row.exif.length > 0) withExif += 1
if (Array.isArray(row?.tags) && row.tags.length > 0) withTags += 1
if (Array.isArray(row?.albums) && row.albums.length > 0) withAlbums += 1
for (const album of Array.isArray(row?.albums) ? row.albums : []) {
const albumId = String(album?.albumId ?? '')
if (albumId === '') continue
const entry = albums.get(albumId) ?? { albumId, title: album?.title ?? '', count: 0 }
entry.count += 1
albums.set(albumId, entry)
}
if (!row?.filename) missingFilename.push(String(row?.flickrId ?? ''))
}
return {
total: rows.length,
bytes,
withTakenAt,
withGeo,
withExif,
withTags,
withAlbums,
albums: [...albums.values()].sort((a, b) => b.count - a.count),
missingFilename,
}
}
const REPORT_LABEL = '[flickr:import]'
/** Byte label shared by the report lines and the CLI progress log. */
export const formatArchiveBytes = (bytes) =>
`${((Number(bytes) || 0) / BYTES_PER_MIB).toFixed(1)} MiB`
/**
* Human lines for stdout; the JSON file keeps the full report. Failures and
* invalid entries are never hidden — each one names its reason.
*
* @param {Record<string, any>} report
* @returns {string[]}
*/
export const formatArchiveReport = (report) => {
const label = REPORT_LABEL
const lines = []
lines.push(
`${label} fotos na API: ${report.apiTotal} · coletadas: ${report.collected} · vídeos pulados: ${report.videos} · sem id: ${report.malformed.length}`,
)
if (report.albums !== undefined) {
lines.push(`${label} álbuns: ${report.albums}`)
}
if (report.mode === 'plan') {
lines.push(
`${label} plano (dry-run): novas: ${report.plan.new} · já existiam: ${report.plan.existing} · original via maior tamanho: ${report.plan.fallback} · inválidas: ${report.plan.invalid}`,
)
} else {
lines.push(
`${label} resultado: novas: ${report.summary.created} · já existiam: ${report.summary.existing} · falhas: ${report.summary.failed} · bytes baixados: ${formatArchiveBytes(report.summary.bytes)}`,
)
}
lines.push(`${label} tempo: ${(report.durationMs / 1000).toFixed(1)}s`)
for (const invalid of report.invalidEntries ?? []) {
lines.push(`${label} inválida ${invalid.flickrId}: ${invalid.reason}`)
}
const failures = report.failures ?? report.summary?.failures ?? []
if (failures.length > 0) {
lines.push(`${label} falhas:`)
for (const failure of failures) {
lines.push(
` - ${failure.flickrId} (${failure.stage ?? 'desconhecido'}): ${failure.error ?? 'erro'}`,
)
}
}
return lines
}
/** `--verify` human lines (inventory, read-only). */
export const formatArchiveInventory = (inventory) => {
const label = REPORT_LABEL
const lines = [
`${label} inventário: ${inventory.total} foto(s) · ${formatArchiveBytes(inventory.bytes)}`,
`${label} metadados: data: ${inventory.withTakenAt} · geotag: ${inventory.withGeo} · EXIF: ${inventory.withExif} · tags: ${inventory.withTags} · álbuns: ${inventory.withAlbums}`,
`${label} álbuns no acervo: ${inventory.albums.length}`,
]
for (const album of inventory.albums) {
lines.push(` - ${album.title || album.albumId} (${album.albumId}): ${album.count}`)
}
if (inventory.missingFilename.length > 0) {
lines.push(`${label} sem arquivo armazenado: ${inventory.missingFilename.join(', ')}`)
}
return lines
}

View file

@ -22,32 +22,13 @@ import {
reelMediaFieldByKind,
} from '../../src/lib/reel.ts'
import {
ALLOW_REMOTE_DB_FLAG,
TEST_DATABASE_NAME_RE,
databaseHostname,
databaseName,
isLocalDatabaseUrl,
isTruthyEnv,
parseEqualsFlags,
} from './cli.mjs'
import { assertEnvironmentDatabaseTarget, isTruthyEnv, parseEqualsFlags } from './cli.mjs'
/** Explicit write-intent flag; dry-run (the default) never needs it. */
export const REELS_INGEST_CONFIRM_FLAG = 'REELS_INGEST_CONFIRM'
const USAGE = 'uso: `pnpm reels:ingest <diretório> [--apply]`'
/**
* Declared environment → exact database name. The name is the honest
* discriminator on the homeserver: the socat proxy rewrites the host to
* 127.0.0.1 and `NODE_ENV=production` is set in both env files, so
* `assertLocalDatabase` alone cannot tell staging from production.
*/
const REEL_INGEST_DATABASE_BY_ENV = Object.freeze({
staging: 'teqo_staging',
production: 'teqo_1313',
})
/** Identity hash of the shot list written by the production skill (sha256 hex). */
const REEL_INGEST_SHOT_LIST_HASH_RE = /^[a-f0-9]{64}$/
@ -104,7 +85,8 @@ export const parseReelIngestArgs = (argv = process.argv.slice(2)) => {
* (`staging`|`production`) is mandatory and must match the exact database name;
* inside vitest only a `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is
* always refused — the ingest never runs from a workstation against a remote
* database.
* database. The rule lives once in `cli.mjs` (`assertEnvironmentDatabaseTarget`,
* C231); this alias keeps the C195 name and contract.
*
* @param {{
* databaseUrl?: string | null,
@ -114,70 +96,7 @@ export const parseReelIngestArgs = (argv = process.argv.slice(2)) => {
* }} [options]
* @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }}
*/
export const assertReelIngestTarget = ({
databaseUrl = process.env.DATABASE_URL,
teqoEnv = process.env.TEQO_ENV,
allowRemoteDb = isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]),
isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST),
} = {}) => {
if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') {
throw new Error(
'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.',
)
}
const targetDatabaseName = databaseName(databaseUrl)
let protocol = null
try {
protocol = new URL(String(databaseUrl ?? '')).protocol
} catch {
protocol = null
}
if (protocol !== 'postgresql:') {
throw new Error(
`protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — o ingest só escreve num alvo PostgreSQL.`,
)
}
if (!isLocalDatabaseUrl(databaseUrl)) {
// Never echo the URL — it carries the DB password.
throw new Error(
`host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — o ingest nunca escreve em alvo remoto.`,
)
}
if (allowRemoteDb) {
throw new Error(
'ALLOW_REMOTE_DB está setado — remova; o ingest não precisa do override remoto.',
)
}
if (isTest) {
if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — o ingest em teste só escreve em "teqo*_test".`,
)
}
return { environment: 'test', databaseName: targetDatabaseName }
}
if (teqoEnv !== 'staging' && teqoEnv !== 'production') {
throw new Error(
`TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`,
)
}
const expected = REEL_INGEST_DATABASE_BY_ENV[teqoEnv]
if (targetDatabaseName !== expected) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` +
'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.',
)
}
return { environment: teqoEnv, databaseName: targetDatabaseName }
}
export const assertReelIngestTarget = (options = {}) => assertEnvironmentDatabaseTarget(options)
/**
* All reasons a parsed metadata + file listing is not a valid package. Empty

View file

@ -74,6 +74,8 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/dossieResearch.mjs',
'scripts/lib/dossieUnit.mjs',
'scripts/lib/e2e-affected-manifest.mjs',
'scripts/lib/flickrApi.mjs',
'scripts/lib/flickrPlan.mjs',
'scripts/lib/github-api.mjs',
'scripts/lib/github-branch-protection.mjs',
'scripts/lib/github-pr-flow.mjs',

View file

@ -0,0 +1,162 @@
import type { CollectionConfig } from 'payload'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import { canReadArchivePhoto, payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* C231 — private upload collection of the Flickr photo archive: the ORIGINAL
* file plus the metadata the Flickr listing brought (date, albums,
* title/description/tags, geotag, EXIF). Like `internetSpeechMedia` and
* `recordingMedia`, deliberately NOT `media`: that collection is anonymous-read
* by contract (public pages), while the raw archive may only be read by the
* communication roles. It is ingested by `pnpm flickr:import` and nothing here
* publishes — C232 curates, C233/C234 publish.
*
* `alt` is required by the upload contract and derived by the ingestion
* (`archivePhotoAlt`); the remaining Flickr fields document provenance and feed
* the catalogue. The stored name is deterministic (`flickr-<id>.<ext>`), so a
* re-run converges by overwriting the same object key.
*/
export const ArchivePhoto: CollectionConfig = {
slug: ARCHIVE_PHOTO_SLUG,
labels: {
singular: 'Foto do acervo',
plural: 'Fotos do acervo',
},
admin: {
group: 'Comunicação',
description:
'Originais e metadados do acervo de fotos do Flickr (conta depjorgesolla). Só abrem com login da campanha.',
},
access: {
create: payloadAdminOnly,
read: canReadArchivePhoto,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'flickrId',
type: 'text',
label: 'ID no Flickr',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Identidade da foto no Flickr — a chave que torna a ingestão idempotente.',
},
},
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do arquivo para acessibilidade.',
},
},
{
name: 'title',
type: 'text',
label: 'Título no Flickr',
},
{
name: 'description',
type: 'textarea',
label: 'Descrição no Flickr',
},
{
name: 'tags',
type: 'array',
label: 'Tags do Flickr',
labels: { singular: 'Tag', plural: 'Tags' },
fields: [
{
name: 'name',
type: 'text',
label: 'Tag',
required: true,
},
],
},
{
name: 'takenAt',
type: 'text',
label: 'Data da foto',
admin: {
description: 'Data informada pelo Flickr, sem fuso (YYYY-MM-DD HH:MM:SS).',
},
},
{
name: 'postedAt',
type: 'text',
label: 'Publicada em',
admin: {
description: 'Data de publicação no Flickr (ISO 8601, UTC).',
},
},
{
name: 'albums',
type: 'array',
label: 'Álbuns',
labels: { singular: 'Álbum', plural: 'Álbuns' },
fields: [
{
name: 'albumId',
type: 'text',
label: 'ID do álbum',
required: true,
},
{
name: 'title',
type: 'text',
label: 'Título do álbum',
required: true,
},
],
},
{
name: 'geo',
type: 'group',
label: 'Geotag',
fields: [
{
name: 'latitude',
type: 'number',
label: 'Latitude',
},
{
name: 'longitude',
type: 'number',
label: 'Longitude',
},
],
},
{
name: 'exif',
type: 'json',
label: 'EXIF',
admin: {
description: 'Metadados EXIF da foto, como o Flickr respondeu ({ tag, label, value }).',
},
},
{
name: 'sourceUrl',
type: 'text',
label: 'Página no Flickr',
},
{
name: 'owner',
type: 'text',
label: 'Conta (NSID)',
},
{
name: 'license',
type: 'text',
label: 'Licença (Flickr)',
},
],
upload: true,
}

279
src/lib/archivePhoto.ts Normal file
View file

@ -0,0 +1,279 @@
/**
* C231 — pure rules of the Flickr photo archive: the collection slug, the
* original-file vocabulary, the deterministic storage name and the mapping
* from the Flickr API listing item to the `archivePhoto` record. No I/O and no
* `server-only`: the ingest CLI (`scripts/lib/flickrPlan.mjs`), the
* Payload-side ingestion, the int spec and the unit tests share this module.
*
* The archive is raw material: nothing here publishes, curates or edits — the
* metadata the Flickr listing brought is preserved as data (C232 curates these
* same rows later; the Flickr account stays the source and is never touched).
*/
/** Owner of the private upload collection slug (collection, S3 map and CLI). */
export const ARCHIVE_PHOTO_SLUG = 'archivePhoto' as const
/**
* Where the stored original came from: the listing's `url_o` ("original") or
* the largest `flickr.photos.getSizes` entry when the original is restricted
* ("largest"). Travels in the receipt so a fallback is never silent.
*/
type ArchivePhotoOriginalKind = 'original' | 'largest'
/** One album membership of a photo (a photo may live in N albums). */
export type ArchivePhotoAlbum = {
albumId: string
title: string
}
/** Both coordinates or no geotag at all — never half a point. */
export type ArchivePhotoGeo = {
latitude: number
longitude: number
}
export type ArchivePhotoExifEntry = {
tag: string
label: string
value: string
}
/**
* The normalized record the CLI hands to `ingestArchivePhoto`. `alt` is derived
* (`archivePhotoAlt`), never carried here.
*/
export type ArchivePhotoImport = {
flickrId: string
sourceUrl: string | null
owner: string | null
license: string | null
title: string | null
description: string | null
tags: string[]
/** Flickr wall-clock `YYYY-MM-DD HH:MM:SS` (no timezone in the source). */
takenAt: string | null
/** Upload time as ISO UTC (derived from `dateupload`). */
postedAt: string | null
albums: ArchivePhotoAlbum[]
geo: ArchivePhotoGeo | null
exif: ArchivePhotoExifEntry[]
originalUrl: string
originalKind: ArchivePhotoOriginalKind
}
const FLICKR_ARCHIVE_PHOTO_FILENAME_DEFAULT_EXTENSION = '.jpg'
/** Flickr's zoneless wall clock: `YYYY-MM-DD[ HH:MM:SS]`. */
const FLICKR_TAKEN_AT_RE = /^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?$/
const FLICKR_ID_RE = /^\d{1,20}$/
const URL_EXTENSION_RE = /\.([a-z0-9]{2,5})$/i
const trimmed = (value: unknown): string | null => {
if (typeof value !== 'string') return null
const text = value.trim()
return text === '' ? null : text
}
const httpUrl = (value: unknown): string | null => {
const text = trimmed(value)
if (text === null) return null
return text.startsWith('https://') || text.startsWith('http://') ? text : null
}
/** Flickr labels a missing geotag as (0, 0) — never a real Bahia coordinate. */
const isMissingGeo = (latitude: number, longitude: number): boolean =>
latitude === 0 && longitude === 0
/**
* Deterministic stored name: `flickr-<id>.<ext>`, extension from the original
* URL (`.jpeg` normalized to `.jpg`, `.jpg` when absent/unknown). The name is
* the idempotency key of the OBJECT too — a re-run overwrites the same key.
*/
export const archivePhotoStorageFilename = (flickrId: string, originalUrl: string): string => {
const id = String(flickrId).replace(/[^0-9A-Za-z_-]/g, '')
let extension = FLICKR_ARCHIVE_PHOTO_FILENAME_DEFAULT_EXTENSION
try {
const match = URL_EXTENSION_RE.exec(new URL(String(originalUrl)).pathname)
if (match) {
const found = `.${match[1].toLowerCase()}`
extension = found === '.jpeg' ? '.jpg' : found
}
} catch {
// Malformed URL: the default extension is the documented fallback.
}
return `flickr-${id}${extension}`
}
/** The Flickr page of a photo (path alias when the listing brought one). */
export const archivePhotoSourceUrl = ({
flickrId,
pathAlias,
}: {
flickrId: string
pathAlias: string | null
}): string =>
pathAlias
? `https://www.flickr.com/photos/${encodeURIComponent(pathAlias)}/${flickrId}/`
: `https://www.flickr.com/photo.gne?id=${flickrId}`
/** Accessible alt of the stored original; C232 refines the curated copy. */
export const archivePhotoAlt = (record: { title: string | null; flickrId: string }): string =>
record.title?.trim() || `Foto do acervo (Flickr ${record.flickrId})`
/** Flickr `datetaken` is a zoneless wall clock; keep it verbatim (or null). */
export const archivePhotoTakenAt = (value: unknown): string | null => {
const text = trimmed(value)
if (text === null || !FLICKR_TAKEN_AT_RE.test(text)) return null
return text.length === 10 ? `${text} 00:00:00` : text
}
/** `dateupload` (epoch seconds, string or number) → ISO UTC (or null). */
export const archivePhotoPostedAt = (value: unknown): string | null => {
const seconds = typeof value === 'number' ? value : Number(trimmed(value))
if (!Number.isFinite(seconds) || seconds <= 0) return null
return new Date(seconds * 1000).toISOString()
}
/**
* Listing `tags` rides as one space-separated string. Arrays are accepted for
* the defensive path; empties drop, exact duplicates collapse, order stays.
*/
export const archivePhotoTags = (value: unknown): string[] => {
const words = Array.isArray(value)
? value.flatMap((entry) => (typeof entry === 'string' ? entry.split(/\s+/) : []))
: typeof value === 'string'
? value.split(/\s+/)
: []
const seen = new Set<string>()
const tags: string[] = []
for (const word of words) {
const tag = word.trim()
if (tag === '' || seen.has(tag)) continue
seen.add(tag)
tags.push(tag)
}
return tags
}
const numberOrNull = (value: unknown): number | null => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
const text = trimmed(value)
if (text === null) return null
const parsed = Number(text)
return Number.isFinite(parsed) ? parsed : null
}
/** Both coordinates in range and not the (0, 0) "no geotag" sentinel. */
export const archivePhotoGeoFrom = (
latitude: unknown,
longitude: unknown,
): ArchivePhotoGeo | null => {
const lat = numberOrNull(latitude)
const lon = numberOrNull(longitude)
if (lat === null || lon === null) return null
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null
if (isMissingGeo(lat, lon)) return null
return { latitude: lat, longitude: lon }
}
/**
* Normalizes `flickr.photos.getExif` (when it answered) into the stored json:
* one entry per tag, empty values dropped, deterministic order.
*/
export const archivePhotoExifEntries = (value: unknown): ArchivePhotoExifEntry[] => {
if (!Array.isArray(value)) return []
const entries: ArchivePhotoExifEntry[] = []
for (const raw of value) {
if (raw === null || typeof raw !== 'object') continue
const entry = raw as Record<string, unknown>
const tag = trimmed(entry.tag) ?? trimmed(entry.tagspace)
const label = trimmed(entry.label) ?? tag
const value = trimmed(entry.clean) ?? trimmed(entry.raw)
if (tag === null || label === null || value === null) continue
entries.push({ tag, label, value })
}
return entries.sort((a, b) => a.tag.localeCompare(b.tag) || a.label.localeCompare(b.label))
}
const normalizeAlbums = (albums: readonly ArchivePhotoAlbum[]): ArchivePhotoAlbum[] => {
const byId = new Map<string, ArchivePhotoAlbum>()
for (const album of albums) {
const albumId = trimmed(album?.albumId)
const title = trimmed(album?.title)
if (albumId === null || title === null || byId.has(albumId)) continue
byId.set(albumId, { albumId, title })
}
return [...byId.values()].sort((a, b) => a.title.localeCompare(b.title))
}
export type ArchivePhotoImportSource = {
/** One `flickr.people.getPhotos` item (extras included). */
photo: unknown
/** Album memberships of this photo, resolved before any write. */
albums: readonly ArchivePhotoAlbum[]
/** `flickr.photos.getExif` answer, when the per-photo call succeeded. */
exif: unknown
/** Largest `flickr.photos.getSizes` URL — the fallback for a missing `url_o`. */
fallbackOriginalUrl?: string | null
}
export type ArchivePhotoImportResult =
| { ok: true; record: ArchivePhotoImport }
| { ok: false; reason: 'id' | 'not-a-photo' | 'original' }
const licenseOrNull = (value: unknown): string | null => {
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : null
return trimmed(value)
}
/**
* Maps one listing item into the `archivePhoto` record. Only three things can
* reject a photo — a missing/non-numeric id, a video item (own backlog, never
* ingested here) and the absence of BOTH the original and the largest size —
* and each rejection has its own named reason for the receipt.
*/
export const archivePhotoImportFromFlickr = ({
photo,
albums,
exif,
fallbackOriginalUrl = null,
}: ArchivePhotoImportSource): ArchivePhotoImportResult => {
if (photo === null || typeof photo !== 'object') return { ok: false, reason: 'id' }
const item = photo as Record<string, unknown>
const flickrId = trimmed(item.id)
if (flickrId === null || !FLICKR_ID_RE.test(flickrId)) return { ok: false, reason: 'id' }
if (trimmed(item.media) !== 'photo') return { ok: false, reason: 'not-a-photo' }
const original = httpUrl(item.url_o)
const fallback = httpUrl(fallbackOriginalUrl)
const originalUrl = original ?? fallback
if (originalUrl === null) return { ok: false, reason: 'original' }
const description =
item.description !== null && typeof item.description === 'object'
? trimmed((item.description as Record<string, unknown>)._content)
: trimmed(item.description)
const license = licenseOrNull(item.license)
return {
ok: true,
record: {
flickrId,
sourceUrl: archivePhotoSourceUrl({ flickrId, pathAlias: trimmed(item.path_alias) }),
owner: trimmed(item.owner),
license,
title: trimmed(item.title),
description,
tags: archivePhotoTags(item.tags),
takenAt: archivePhotoTakenAt(item.datetaken),
postedAt: archivePhotoPostedAt(item.dateupload),
albums: normalizeAlbums(albums),
geo: archivePhotoGeoFrom(item.latitude, item.longitude),
exif: archivePhotoExifEntries(exif),
originalUrl,
originalKind: original !== null ? 'original' : 'largest',
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// C231 — archivePhoto only. The generated diff was hand-trimmed: the previous
// snapshot in the chain (20260924_123940_add_content_piece_people.json) was
// desynced from earlier merged deliveries (it lost internet_speech_media and
// the recording rels), so `migrate:create` re-created already-existing objects
// (internet_speech_media, recording_topics/scopes/rels, speech.*,
// content_piece.link_failure_reason, recording.year/classified_by) — applying
// those would fail on every database that already ran the chain. The full
// snapshot written alongside this migration restores the accumulated schema
// for the next diff.
await db.execute(sql`
CREATE TABLE "archive_photo_tags" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL
);
CREATE TABLE "archive_photo_albums" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"album_id" varchar NOT NULL,
"title" varchar NOT NULL
);
CREATE TABLE "archive_photo" (
"id" serial PRIMARY KEY NOT NULL,
"flickr_id" varchar NOT NULL,
"alt" varchar NOT NULL,
"title" varchar,
"description" varchar,
"taken_at" varchar,
"posted_at" varchar,
"geo_latitude" numeric,
"geo_longitude" numeric,
"exif" jsonb,
"source_url" varchar,
"owner" varchar,
"license" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "archive_photo_id" integer;
ALTER TABLE "archive_photo_tags" ADD CONSTRAINT "archive_photo_tags_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "archive_photo_albums" ADD CONSTRAINT "archive_photo_albums_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "archive_photo_tags_order_idx" ON "archive_photo_tags" USING btree ("_order");
CREATE INDEX "archive_photo_tags_parent_id_idx" ON "archive_photo_tags" USING btree ("_parent_id");
CREATE INDEX "archive_photo_albums_order_idx" ON "archive_photo_albums" USING btree ("_order");
CREATE INDEX "archive_photo_albums_parent_id_idx" ON "archive_photo_albums" USING btree ("_parent_id");
CREATE UNIQUE INDEX "archive_photo_flickr_id_idx" ON "archive_photo" USING btree ("flickr_id");
CREATE INDEX "archive_photo_updated_at_idx" ON "archive_photo" USING btree ("updated_at");
CREATE INDEX "archive_photo_created_at_idx" ON "archive_photo" USING btree ("created_at");
CREATE UNIQUE INDEX "archive_photo_filename_idx" ON "archive_photo" USING btree ("filename");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_archive_photo_fk" FOREIGN KEY ("archive_photo_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_archive_photo_id_idx" ON "payload_locked_documents_rels" USING btree ("archive_photo_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "archive_photo_tags" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "archive_photo_albums" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "archive_photo" DISABLE ROW LEVEL SECURITY;
DROP TABLE "archive_photo_tags" CASCADE;
DROP TABLE "archive_photo_albums" CASCADE;
DROP TABLE "archive_photo" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_archive_photo_fk";
DROP INDEX "payload_locked_documents_rels_archive_photo_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "archive_photo_id";`)
}

View file

@ -86,6 +86,7 @@ import * as migration_20260924_040210_add_recording_facets from './20260924_0402
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
import * as migration_20260924_045625_add_content_piece_link_failure_reason from './20260924_045625_add_content_piece_link_failure_reason';
import * as migration_20260924_123940_add_content_piece_people from './20260924_123940_add_content_piece_people';
import * as migration_20260926_062039_add_archive_photo from './20260926_062039_add_archive_photo';
export const migrations = [
{
@ -528,4 +529,9 @@ export const migrations = [
down: migration_20260924_123940_add_content_piece_people.down,
name: '20260924_123940_add_content_piece_people',
},
{
up: migration_20260926_062039_add_archive_photo.up,
down: migration_20260926_062039_add_archive_photo.down,
name: '20260926_062039_add_archive_photo'
},
];

View file

@ -94,6 +94,7 @@ export interface Config {
reelMedia: ReelMedia;
contentPiece: ContentPiece;
contentMedia: ContentMedia;
archivePhoto: ArchivePhoto;
contentEvent: ContentEvent;
recording: Recording;
recordingMedia: RecordingMedia;
@ -149,6 +150,7 @@ export interface Config {
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
archivePhoto: ArchivePhotoSelect<false> | ArchivePhotoSelect<true>;
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
recording: RecordingSelect<false> | RecordingSelect<true>;
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
@ -1352,6 +1354,76 @@ export interface ContentMedia {
focalX?: number | null;
focalY?: number | null;
}
/**
* Originais e metadados do acervo de fotos do Flickr (conta depjorgesolla). Só abrem com login da campanha.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "archivePhoto".
*/
export interface ArchivePhoto {
id: number;
/**
* Identidade da foto no Flickr — a chave que torna a ingestão idempotente.
*/
flickrId: string;
/**
* Descrição do arquivo para acessibilidade.
*/
alt: string;
title?: string | null;
description?: string | null;
tags?:
| {
name: string;
id?: string | null;
}[]
| null;
/**
* Data informada pelo Flickr, sem fuso (YYYY-MM-DD HH:MM:SS).
*/
takenAt?: string | null;
/**
* Data de publicação no Flickr (ISO 8601, UTC).
*/
postedAt?: string | null;
albums?:
| {
albumId: string;
title: string;
id?: string | null;
}[]
| null;
geo?: {
latitude?: number | null;
longitude?: number | null;
};
/**
* Metadados EXIF da foto, como o Flickr respondeu ({ tag, label, value }).
*/
exif?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
sourceUrl?: string | null;
owner?: string | null;
license?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* Eventos anônimos de circulação (abertura, download, compartilhamento).
*
@ -2178,6 +2250,10 @@ export interface PayloadLockedDocument {
relationTo: 'contentMedia';
value: number | ContentMedia;
} | null)
| ({
relationTo: 'archivePhoto';
value: number | ArchivePhoto;
} | null)
| ({
relationTo: 'contentEvent';
value: number | ContentEvent;
@ -2900,6 +2976,52 @@ export interface ContentMediaSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "archivePhoto_select".
*/
export interface ArchivePhotoSelect<T extends boolean = true> {
flickrId?: T;
alt?: T;
title?: T;
description?: T;
tags?:
| T
| {
name?: T;
id?: T;
};
takenAt?: T;
postedAt?: T;
albums?:
| T
| {
albumId?: T;
title?: T;
id?: T;
};
geo?:
| T
| {
latitude?: T;
longitude?: T;
};
exif?: T;
sourceUrl?: T;
owner?: T;
license?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentEvent_select".
@ -3753,6 +3875,7 @@ export interface TaskCreateCollectionExport {
| 'reelMedia'
| 'contentPiece'
| 'contentMedia'
| 'archivePhoto'
| 'contentEvent'
| 'recording'
| 'recordingMedia'

View file

@ -11,6 +11,7 @@ import { s3Storage } from '@payloadcms/storage-s3'
import { pt } from 'payload/i18n/pt'
import { Activity } from './collections/Activity'
import { AllocationDecision } from './collections/AllocationDecision'
import { ArchivePhoto } from './collections/ArchivePhoto'
import { CalendarFeed } from './collections/CalendarFeed'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
@ -60,6 +61,7 @@ import { Metadata } from './globals/Metadata'
import { PrivacyPolicy } from './globals/PrivacyPolicy'
import { SiteSettings } from './globals/SiteSettings'
import { SocialFeedSettings } from './globals/SocialFeedSettings'
import { ARCHIVE_PHOTO_SLUG } from './lib/archivePhoto'
import { isPayloadAdmin } from './utilities/campaignAccess'
import { resolveS3StorageEnv } from './utilities/mediaStorage'
@ -133,6 +135,7 @@ export default buildConfig({
ReelMedia,
ContentPiece,
ContentMedia,
ArchivePhoto,
ContentEvent,
Recording,
RecordingMedia,
@ -193,6 +196,8 @@ export default buildConfig({
contentMedia: true,
// C215 — private mirrored web-speech files; same failure mode if omitted.
internetSpeechMedia: true,
// C231 — private Flickr photo-archive originals; same failure mode if omitted.
[ARCHIVE_PHOTO_SLUG]: true,
},
bucket: mediaStorage.bucket,
config: {

View file

@ -0,0 +1,23 @@
// ---------------------------------------------------------------------------
// C231 — Flickr photo archive
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { getFreshCampaignUser, isPayloadAdmin } from '@/utilities/access/shared'
/**
* Readers of the photo archive: Payload admin or campaign users with the
* `communicator`, `coordinator` or `candidate` role — the same vertical gate as
* the acervo de falas, the reels and the Central. Advisors and leaders are
* denied (fail-closed): the raw archive is editorial material, not a
* municipality-scoped staff area. C232 curates these same rows; the CLI
* ingests with an explicit documented bypass.
*/
export const canReadArchivePhoto: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}

View file

@ -191,3 +191,5 @@ export {
canReadContentPiece,
canUpdateContentPiece,
} from '@/utilities/access/contentPieces'
export { canReadArchivePhoto } from '@/utilities/access/archivePhotos'

View file

@ -0,0 +1,141 @@
import 'server-only'
import type { Payload } from 'payload'
import { ARCHIVE_PHOTO_SLUG, archivePhotoAlt, type ArchivePhotoImport } from '@/lib/archivePhoto'
import type { ArchivePhoto } from '@/payload-types'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* C231 — Payload-side ingestion of one Flickr archive photo: the re-check by
* `flickrId`, the transactional upload of the original and the row, and the
* honest result the CLI turns into the receipt. The network side (listing,
* metadata, download) belongs to `pnpm flickr:import`; nothing here touches
* the Flickr API, publishes or curates.
*/
export type ArchivePhotoExisting = {
id: number
filename: string | null
filesize: number | null
}
/**
* The idempotency lookup: `flickrId` is the natural key. Used by the planner
* (dry-run) AND by the ingestion itself, so a re-run never re-downloads what
* is already archived. The documented CLI bypass applies (trusted actor with
* no session).
*/
export const findArchivePhotoByFlickrId = async (
payload: Payload,
flickrId: string,
): Promise<ArchivePhotoExisting | null> => {
const found = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
depth: 0,
limit: 1,
// Intentional bypass: the ingest CLI is a trusted actor with no session.
overrideAccess: true,
})
const doc = found.docs[0]
if (!doc) return null
return { id: doc.id, filename: doc.filename ?? null, filesize: doc.filesize ?? null }
}
/**
* The whole archive, read-only and paged (the `--verify` inventory source).
* The same documented CLI bypass as the lookup applies (trusted actor).
*/
export const listArchivePhotos = async (payload: Payload): Promise<ArchivePhoto[]> => {
const docs: ArchivePhoto[] = []
let page = 1
let totalPages = 1
do {
const found = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
page,
limit: 100,
depth: 0,
sort: 'flickrId',
// Intentional bypass: read-only inventory of the CLI's own collection.
overrideAccess: true,
})
docs.push(...found.docs)
totalPages = found.totalPages
page += 1
} while (page <= totalPages)
return docs
}
export type ArchivePhotoIngestResult =
| { status: 'created'; flickrId: string; id: number; filename: string | null }
| { status: 'existing'; flickrId: string; id: number; filename: string | null }
| { status: 'failed'; flickrId: string; stage: 'ingest'; error: string }
/**
* Creates one archive row and uploads its original in ONE database
* transaction (`req` threaded through the upload). The object write itself is
* not part of the DB transaction: a rollback can leave an object behind, and
* the deterministic storage name + `overwriteExistingFiles` self-heals it on
* the next run. The existence re-check makes the write idempotent even if the
* planner ran earlier. Every failure is returned, never thrown: the caller
* owns the receipt.
*/
export const ingestArchivePhoto = async (
payload: Payload,
record: ArchivePhotoImport,
{ filePath }: { filePath: string },
): Promise<ArchivePhotoIngestResult> => {
const existing = await findArchivePhotoByFlickrId(payload, record.flickrId)
if (existing) {
return {
status: 'existing',
flickrId: record.flickrId,
id: existing.id,
filename: existing.filename,
}
}
try {
const doc = await withPayloadTransaction(payload, async ({ req }) =>
payload.create({
collection: ARCHIVE_PHOTO_SLUG,
data: {
flickrId: record.flickrId,
alt: archivePhotoAlt(record),
title: record.title,
description: record.description,
tags: record.tags.map((name) => ({ name })),
takenAt: record.takenAt,
postedAt: record.postedAt,
albums: record.albums,
...(record.geo ? { geo: record.geo } : {}),
exif: record.exif,
sourceUrl: record.sourceUrl,
owner: record.owner,
license: record.license,
},
filePath,
req,
overwriteExistingFiles: true,
// Intentional bypass: the ingestion CLI is a trusted actor with no session.
overrideAccess: true,
}),
)
return {
status: 'created',
flickrId: record.flickrId,
id: doc.id,
filename: doc.filename ?? null,
}
} catch (error) {
return {
status: 'failed',
flickrId: record.flickrId,
stage: 'ingest',
error: error instanceof Error ? error.message : String(error),
}
}
}

View file

@ -23,17 +23,21 @@ export const downloadUrlToFile = async ({
destinationPath,
timeoutMs = DOWNLOAD_TIMEOUT_MS,
maxBytes = DOWNLOAD_MAX_BYTES,
headers,
fetchImpl = fetch,
}: {
url: string
destinationPath: string
timeoutMs?: number
maxBytes?: number
/** Extra request headers (C231 — the Flickr CDN needs its User-Agent). */
headers?: HeadersInit
fetchImpl?: typeof fetch
}): Promise<void> => {
const response = await fetchImpl(url, {
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
...(headers ? { headers } : {}),
})
if (!response.ok) throw new Error(`Download falhou (HTTP ${response.status}).`)
if (!response.body) throw new Error('Download sem corpo.')

View file

@ -0,0 +1,9 @@
/**
* C231 — real-bytes fixture of the archive-photo int specs: Payload runs sharp
* over image uploads (focal point + dimensions), so the original fixture must
* decode like a real image. 2×2 JPEG generated with sharp.
*/
export const ARCHIVE_PHOTO_JPEG_BYTES = Buffer.from(
'/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAL/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCTgJH/2Q==',
'base64',
)

View file

@ -0,0 +1,227 @@
// @vitest-environment node
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
import config from '@/payload.config'
import {
findArchivePhotoByFlickrId,
ingestArchivePhoto,
listArchivePhotos,
} from '@/utilities/flickr/archivePhotoIngest'
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
// C231 — the ingestion boundary over the real Payload `teqo_test`: the
// transactional create of row + original, the `flickrId` idempotency, the
// honest failure and the access gate (communication roles read; only the
// Payload admin mutates). The Flickr network side never enters the suite.
let payload: Payload
const createdFlickrIds = new Set<string>()
const tempDirs: string[] = []
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const record = (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): ArchivePhotoImport => {
createdFlickrIds.add(flickrId)
return {
flickrId,
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
owner: '12345678@N00',
license: '0',
title: `Foto ${flickrId}`,
description: 'Registro do mandato',
tags: ['saude', 'salvador'],
takenAt: '2025-03-14 10:20:30',
postedAt: '2025-03-15T10:00:00.000Z',
albums: [{ albumId: '721777', title: 'Saúde' }],
geo: { latitude: -12.2664, longitude: -38.9663 },
exif: [{ tag: 'Make', label: 'Fabricante', value: 'Canon' }],
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
originalKind: 'original',
...overrides,
}
}
const writeOriginal = async (flickrId: string): Promise<string> => {
const dir = await mkdtemp(join(tmpdir(), 'archive-photo-'))
tempDirs.push(dir)
const filePath = join(dir, `flickr-${flickrId}.jpg`)
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
return filePath
}
describe('ingestArchivePhoto (C231)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
it('creates the row and the stored original in one go', async () => {
const item = record('53123456789')
const filePath = await writeOriginal(item.flickrId)
const result = await ingestArchivePhoto(payload, item, { filePath })
expect(result).toMatchObject({ status: 'created', flickrId: item.flickrId })
const existing = await findArchivePhotoByFlickrId(payload, item.flickrId)
expect(existing?.filename).toBe(`flickr-${item.flickrId}.jpg`)
const found = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: item.flickrId } },
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(found.docs).toHaveLength(1)
expect(found.docs[0]).toMatchObject({
flickrId: item.flickrId,
alt: `Foto ${item.flickrId}`,
title: item.title,
description: item.description,
tags: [{ name: 'saude' }, { name: 'salvador' }],
takenAt: item.takenAt,
postedAt: item.postedAt,
albums: [{ albumId: '721777', title: 'Saúde' }],
geo: { latitude: -12.2664, longitude: -38.9663 },
sourceUrl: item.sourceUrl,
owner: item.owner,
license: item.license,
mimeType: 'image/jpeg',
})
expect(found.docs[0]?.exif).toEqual([{ tag: 'Make', label: 'Fabricante', value: 'Canon' }])
const inventory = await listArchivePhotos(payload)
expect(inventory.map((row) => row.flickrId)).toContain(item.flickrId)
})
it('converges on a re-run: the same flickrId is never duplicated nor re-created', async () => {
const item = record('53123456790')
const first = await ingestArchivePhoto(payload, item, {
filePath: await writeOriginal(item.flickrId),
})
expect(first.status).toBe('created')
const second = await ingestArchivePhoto(payload, item, {
filePath: await writeOriginal(item.flickrId),
})
expect(second).toMatchObject({ status: 'existing', flickrId: item.flickrId })
const found = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: item.flickrId } },
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(found.docs).toHaveLength(1)
})
it('reports a failed write and leaves no row behind', async () => {
const item = record('53123456791')
const result = await ingestArchivePhoto(payload, item, {
filePath: join(tmpdir(), 'nao-existe', 'flickr.jpg'),
})
expect(result).toMatchObject({ status: 'failed', flickrId: item.flickrId, stage: 'ingest' })
if (result.status !== 'failed') return
expect(result.error.length).toBeGreaterThan(0)
expect(await findArchivePhotoByFlickrId(payload, item.flickrId)).toBeNull()
})
it('reads only with the communication roles', async () => {
const item = record('53123456792')
await ingestArchivePhoto(payload, item, { filePath: await writeOriginal(item.flickrId) })
const fixtures = campaignFixtures()
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
const readAs = (user?: { collection: string; id: number }) =>
payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: item.flickrId } },
depth: 0,
limit: 1,
...(user ? { user } : {}),
overrideAccess: false,
})
await expect(readAs()).rejects.toThrow()
await expect(readAs(advisor)).rejects.toThrow()
await expect(readAs(leader)).rejects.toThrow()
expect((await readAs(communicator)).docs).toHaveLength(1)
expect((await readAs(coordinator)).docs).toHaveLength(1)
expect((await readAs(candidate)).docs).toHaveLength(1)
})
it('reserves the mutations to the Payload admin', async () => {
const item = record('53123456793')
await ingestArchivePhoto(payload, item, { filePath: await writeOriginal(item.flickrId) })
const existing = await findArchivePhotoByFlickrId(payload, item.flickrId)
if (!existing) throw new Error('archive photo fixture was not created')
const fixtures = campaignFixtures()
const communicator = await fixtures.createCampaignUser('communicator')
const admin = await fixtures.createAdminUser()
await expect(
payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: existing.id,
data: { title: 'Editado' },
overrideAccess: false,
}),
).rejects.toThrow()
await expect(
payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: existing.id,
data: { title: 'Editado' },
user: communicator,
overrideAccess: false,
}),
).rejects.toThrow()
const updated = await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: existing.id,
data: { title: 'Editado' },
user: admin,
overrideAccess: false,
})
expect(updated.title).toBe('Editado')
})
})

View file

@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest'
import {
archivePhotoAlt,
archivePhotoExifEntries,
archivePhotoGeoFrom,
archivePhotoImportFromFlickr,
archivePhotoPostedAt,
archivePhotoSourceUrl,
archivePhotoStorageFilename,
archivePhotoTags,
archivePhotoTakenAt,
} from '@/lib/archivePhoto'
// C231 — the pure rules of the Flickr archive mapping: the original naming,
// the metadata normalization (tags/geo/EXIF/dates) and the three named
// rejection reasons. Real API payloads never enter the suite.
const listingPhoto = (overrides: Record<string, unknown> = {}) => ({
id: '53123456789',
owner: '12345678@N00',
title: 'Comício em Feira de Santana',
description: { _content: 'Registro do comício.' },
datetaken: '2025-03-14 10:20:30',
dateupload: '1742032800',
license: '0',
tags: 'saude salvador saude',
latitude: '-12.2664',
longitude: '-38.9663',
media: 'photo',
url_o: 'https://live.staticflickr.com/65535/53123456789_abcdef_o.jpg',
path_alias: 'depjorgesolla',
...overrides,
})
describe('archivePhotoStorageFilename (C231)', () => {
it('derives the deterministic name from the original URL', () => {
expect(
archivePhotoStorageFilename('53123456789', 'https://live.staticflickr.com/1/2_o.JPG'),
).toBe('flickr-53123456789.jpg')
expect(
archivePhotoStorageFilename('53123456789', 'https://live.staticflickr.com/1/2_o.jpeg'),
).toBe('flickr-53123456789.jpg')
expect(
archivePhotoStorageFilename('53123456789', 'https://live.staticflickr.com/1/2_o.png'),
).toBe('flickr-53123456789.png')
})
it('falls back to .jpg for an extensionless or malformed URL', () => {
expect(archivePhotoStorageFilename('42', 'https://live.staticflickr.com/1/2_o')).toBe(
'flickr-42.jpg',
)
expect(archivePhotoStorageFilename('42', 'not a url')).toBe('flickr-42.jpg')
})
it('keeps only filename-safe id characters', () => {
expect(archivePhotoStorageFilename('53/../evil', 'https://x/1_o.jpg')).toBe('flickr-53evil.jpg')
})
})
describe('archivePhotoAlt (C231)', () => {
it('prefers the Flickr title and falls back to the id', () => {
expect(archivePhotoAlt({ title: ' Comício ', flickrId: '1' })).toBe('Comício')
expect(archivePhotoAlt({ title: ' ', flickrId: '1' })).toBe('Foto do acervo (Flickr 1)')
expect(archivePhotoAlt({ title: null, flickrId: '1' })).toBe('Foto do acervo (Flickr 1)')
})
})
describe('archivePhotoSourceUrl (C231)', () => {
it('uses the path alias when present and the id-only page when absent', () => {
expect(archivePhotoSourceUrl({ flickrId: '42', pathAlias: 'depjorgesolla' })).toBe(
'https://www.flickr.com/photos/depjorgesolla/42/',
)
expect(archivePhotoSourceUrl({ flickrId: '42', pathAlias: null })).toBe(
'https://www.flickr.com/photo.gne?id=42',
)
})
})
describe('archivePhotoTakenAt / archivePhotoPostedAt (C231)', () => {
it('keeps the zoneless wall clock, completing a date-only value', () => {
expect(archivePhotoTakenAt('2025-03-14 10:20:30')).toBe('2025-03-14 10:20:30')
expect(archivePhotoTakenAt('2025-03-14')).toBe('2025-03-14 00:00:00')
expect(archivePhotoTakenAt('14/03/2025')).toBeNull()
expect(archivePhotoTakenAt(undefined)).toBeNull()
})
it('derives the ISO UTC upload time from epoch seconds', () => {
expect(archivePhotoPostedAt('1742032800')).toBe(new Date(1742032800_000).toISOString())
expect(archivePhotoPostedAt(1742032800)).toBe(new Date(1742032800_000).toISOString())
expect(archivePhotoPostedAt('0')).toBeNull()
expect(archivePhotoPostedAt('ontem')).toBeNull()
})
})
describe('archivePhotoTags (C231)', () => {
it('splits the space-separated listing value and collapses duplicates', () => {
expect(archivePhotoTags('saude salvador saude bahia')).toEqual(['saude', 'salvador', 'bahia'])
expect(archivePhotoTags(['saude bahia', 'saude'])).toEqual(['saude', 'bahia'])
expect(archivePhotoTags('')).toEqual([])
expect(archivePhotoTags(null)).toEqual([])
})
})
describe('archivePhotoGeoFrom (C231)', () => {
it('accepts a real point and refuses the (0, 0) sentinel or out-of-range values', () => {
expect(archivePhotoGeoFrom('-12.2664', '-38.9663')).toEqual({
latitude: -12.2664,
longitude: -38.9663,
})
expect(archivePhotoGeoFrom(0, 0)).toBeNull()
expect(archivePhotoGeoFrom('91', '0.5')).toBeNull()
expect(archivePhotoGeoFrom('-12.5', '181')).toBeNull()
expect(archivePhotoGeoFrom('', undefined)).toBeNull()
})
it('never returns half a point when one axis is missing', () => {
expect(archivePhotoGeoFrom('-12.2664', undefined)).toBeNull()
expect(archivePhotoGeoFrom(undefined, '-38.9663')).toBeNull()
expect(archivePhotoGeoFrom('-12.2664', '')).toBeNull()
expect(archivePhotoGeoFrom('', '-38.9663')).toBeNull()
expect(archivePhotoGeoFrom(null, null)).toBeNull()
})
})
describe('archivePhotoExifEntries (C231)', () => {
it('normalizes the API answer, preferring clean values and sorting by tag', () => {
expect(
archivePhotoExifEntries([
{ tag: 'Model', label: 'Modelo', raw: 'Canon EOS', clean: 'Canon EOS R6' },
{ tag: 'Make', label: 'Fabricante', raw: 'Canon' },
{ tag: 'Empty', raw: ' ' },
{ tag: 'NoValue' },
'lixo',
]),
).toEqual([
{ tag: 'Make', label: 'Fabricante', value: 'Canon' },
{ tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' },
])
expect(archivePhotoExifEntries(null)).toEqual([])
})
})
describe('archivePhotoImportFromFlickr (C231)', () => {
it('maps a complete listing item into the archive record', () => {
const result = archivePhotoImportFromFlickr({
photo: listingPhoto(),
albums: [{ albumId: '721777', title: 'Saúde' }],
exif: [{ tag: 'Make', label: 'Fabricante', raw: 'Canon' }],
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.record).toEqual({
flickrId: '53123456789',
sourceUrl: 'https://www.flickr.com/photos/depjorgesolla/53123456789/',
owner: '12345678@N00',
license: '0',
title: 'Comício em Feira de Santana',
description: 'Registro do comício.',
tags: ['saude', 'salvador'],
takenAt: '2025-03-14 10:20:30',
postedAt: new Date(1742032800_000).toISOString(),
albums: [{ albumId: '721777', title: 'Saúde' }],
geo: { latitude: -12.2664, longitude: -38.9663 },
exif: [{ tag: 'Make', label: 'Fabricante', value: 'Canon' }],
originalUrl: 'https://live.staticflickr.com/65535/53123456789_abcdef_o.jpg',
originalKind: 'original',
})
})
it('falls back to the largest size and collapses albums deterministically', () => {
const result = archivePhotoImportFromFlickr({
photo: listingPhoto({ url_o: undefined }),
albums: [
{ albumId: 'b', title: 'Comícios' },
{ albumId: 'a', title: 'Saúde' },
{ albumId: 'b', title: 'Duplicado' },
],
exif: null,
fallbackOriginalUrl: 'https://live.staticflickr.com/1/2_b.jpg',
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.record.originalKind).toBe('largest')
expect(result.record.originalUrl).toBe('https://live.staticflickr.com/1/2_b.jpg')
expect(result.record.albums).toEqual([
{ albumId: 'b', title: 'Comícios' },
{ albumId: 'a', title: 'Saúde' },
])
})
it('rejects each failure with its own named reason', () => {
expect(
archivePhotoImportFromFlickr({ photo: listingPhoto({ id: 'abc' }), albums: [], exif: null }),
).toEqual({ ok: false, reason: 'id' })
expect(
archivePhotoImportFromFlickr({
photo: listingPhoto({ media: 'video' }),
albums: [],
exif: null,
}),
).toEqual({ ok: false, reason: 'not-a-photo' })
expect(
archivePhotoImportFromFlickr({
photo: listingPhoto({ url_o: undefined }),
albums: [],
exif: null,
}),
).toEqual({ ok: false, reason: 'original' })
})
it('tolerates absent optional metadata and a numeric license', () => {
const result = archivePhotoImportFromFlickr({
photo: listingPhoto({
title: '',
description: undefined,
tags: undefined,
datetaken: 'sem data',
dateupload: undefined,
latitude: '0',
longitude: '0',
license: 4,
path_alias: undefined,
}),
albums: [],
exif: undefined,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.record).toMatchObject({
title: null,
description: null,
tags: [],
takenAt: null,
postedAt: null,
geo: null,
exif: [],
license: '4',
sourceUrl: 'https://www.flickr.com/photo.gne?id=53123456789',
})
})
it('rejects a non-scalar license instead of stringifying it', () => {
const result = archivePhotoImportFromFlickr({
photo: listingPhoto({ license: { id: 4 } }),
albums: [],
exif: null,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.record.license).toBeNull()
})
})

View file

@ -0,0 +1,174 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { FLICKR_PHOTO_EXTRAS, createFlickrClient } from '../../scripts/lib/flickrApi.mjs'
// C231 — the Flickr client contract: request shape, pacing, transient retry,
// named `stat=fail` refusals, the exif tolerance and the largest-size
// fallback. `fetch` is always a fake — the real API never enters the suite.
const jsonResponse = (body: unknown, status = 200) =>
({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}) as Response
const fakeFetch = (impl: (url: string, init?: RequestInit) => Promise<Response>) =>
vi.fn(impl) as unknown as typeof fetch
const clientWith = (
fetchImpl: typeof fetch,
overrides: Record<string, unknown> = {},
): ReturnType<typeof createFlickrClient> =>
createFlickrClient({
apiKey: 'test-key',
fetchImpl,
pacingMs: 0,
retryAttempts: 1,
...overrides,
})
describe('createFlickrClient (C231)', () => {
it('refuses to be created without the API key', () => {
expect(() => createFlickrClient({ apiKey: ' ' })).toThrow(/FLICKR_API_KEY/)
expect(() => createFlickrClient()).toThrow(/FLICKR_API_KEY/)
})
it('sends the method, key, JSON contract and extras; parses the listing', async () => {
const fetchImpl = fakeFetch(async () =>
jsonResponse({
stat: 'ok',
photos: { page: 1, pages: 2, total: 7000, photo: [{ id: '1' }] },
}),
)
const client = clientWith(fetchImpl)
const photos = await client.listPhotosPage({ userId: '123@N00', page: 2 })
const called = new URL(
String((fetchImpl as unknown as { mock: { calls: string[][] } }).mock.calls[0][0]),
)
expect(called.origin + called.pathname).toBe('https://api.flickr.com/services/rest/')
expect(called.searchParams.get('method')).toBe('flickr.people.getPhotos')
expect(called.searchParams.get('api_key')).toBe('test-key')
expect(called.searchParams.get('nojsoncallback')).toBe('1')
expect(called.searchParams.get('user_id')).toBe('123@N00')
expect(called.searchParams.get('page')).toBe('2')
expect(called.searchParams.get('extras')).toBe(FLICKR_PHOTO_EXTRAS)
expect(photos).toMatchObject({ page: 1, pages: 2, total: 7000 })
})
it('paces sequential calls with the configured interval', async () => {
const sleeps: number[] = []
let clock = 0
const fetchImpl = fakeFetch(async () =>
jsonResponse({ stat: 'ok', photos: { page: 1, pages: 1, total: 0, photo: [] } }),
)
const client = createFlickrClient({
apiKey: 'k',
fetchImpl,
pacingMs: 1000,
retryAttempts: 1,
sleep: async (ms: number) => {
sleeps.push(ms)
clock += ms
},
now: () => clock,
})
await client.call('a')
clock += 200
await client.call('b')
expect(sleeps).toEqual([800])
})
it('retries HTTP 429/5xx and thrown network errors with growing backoff', async () => {
const sleeps: number[] = []
const serverError = fakeFetch(async () => jsonResponse({}, 500))
const failing = clientWith(serverError, {
retryAttempts: 3,
retryBaseDelayMs: 100,
sleep: async (ms: number) => {
sleeps.push(ms)
},
})
await expect(failing.call('x')).rejects.toMatchObject({
name: 'FlickrApiError',
status: 500,
})
expect(serverError).toHaveBeenCalledTimes(3)
expect(sleeps).toEqual([100, 200])
const rateLimited = fakeFetch(async () => jsonResponse({}, 429))
await expect(clientWith(rateLimited, { retryAttempts: 2 }).call('x')).rejects.toMatchObject({
status: 429,
})
expect(rateLimited).toHaveBeenCalledTimes(2)
const networkError = fakeFetch(async () => {
throw new Error('getaddrinfo ENOTFOUND')
})
await expect(clientWith(networkError, { retryAttempts: 2 }).call('x')).rejects.toThrow(
'ENOTFOUND',
)
expect(networkError).toHaveBeenCalledTimes(2)
})
it('never retries a named stat=fail refusal', async () => {
const fetchImpl = fakeFetch(async () =>
jsonResponse({ stat: 'fail', code: 100, message: 'Invalid API Key' }),
)
const client = clientWith(fetchImpl, { retryAttempts: 4 })
await expect(client.call('x')).rejects.toThrow('Flickr 100: Invalid API Key')
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('tolerates an exif refusal but propagates transport failures', async () => {
const exifFail = fakeFetch(async () =>
jsonResponse({ stat: 'fail', code: 2, message: 'Photo not found' }),
)
expect(await clientWith(exifFail).getExif('1')).toEqual([])
const exifBody = fakeFetch(async () =>
jsonResponse({ stat: 'ok', photo: { id: '1', exif: [{ tag: 'Make', raw: 'Canon' }] } }),
)
expect(await clientWith(exifBody).getExif('1')).toEqual([{ tag: 'Make', raw: 'Canon' }])
const httpError = fakeFetch(async () => jsonResponse({}, 503))
await expect(clientWith(httpError).getExif('1')).rejects.toMatchObject({ status: 503 })
})
it('picks the largest image size, ignoring videos and sizes without dimensions', async () => {
const fetchImpl = fakeFetch(async () =>
jsonResponse({
stat: 'ok',
sizes: {
size: [
{ label: 'Small', media: 'photo', width: 240, height: 160, source: 'small' },
{ label: 'Original', media: 'photo', width: 4000, height: 3000, source: 'original' },
{ label: 'Sem dims', media: 'photo', source: 'no-dims' },
{ label: 'Vídeo', media: 'video', width: 9999, height: 9999, source: 'video' },
],
},
}),
)
expect(await clientWith(fetchImpl).getLargestSize('1')).toEqual({ url: 'original' })
const noSizes = fakeFetch(async () => jsonResponse({ stat: 'ok', sizes: { size: [] } }))
expect(await clientWith(noSizes).getLargestSize('1')).toBeNull()
const onlyUndimensioned = fakeFetch(async () =>
jsonResponse({
stat: 'ok',
sizes: { size: [{ media: 'photo', source: 'no-dims' }] },
}),
)
expect(await clientWith(onlyUndimensioned).getLargestSize('1')).toBeNull()
})
})

View file

@ -0,0 +1,83 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// C231 — the CLI's fail-closed write guards, exercised as a subprocess: each
// refusal must happen before any Flickr call, S3 write or DB connection.
const repoRoot = process.cwd()
const scriptPath = join(repoRoot, 'scripts', 'ingest-flickr-archive.mjs')
const run = (args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 30_000,
env: {
...process.env,
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
NODE_ENV: 'production',
VITEST: '',
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
PAYLOAD_SECRET: 'test-secret',
FLICKR_API_KEY: 'test-key',
FLICKR_USER_ID: '123@N00',
FLICKR_IMPORT_CONFIRM: '',
TEQO_ENV: '',
ALLOW_REMOTE_DB: '',
S3_BUCKET: '',
S3_ENDPOINT: '',
S3_ACCESS_KEY_ID: '',
S3_SECRET_ACCESS_KEY: '',
...env,
},
})
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
describe('flickr:import write guards (C231)', () => {
it('prints the help even without a database', () => {
const result = run(['--help'], { DATABASE_URL: '' })
expect(result.status).toBe(0)
expect(result.stdout).toContain('FLICKR_IMPORT_CONFIRM')
expect(result.stdout).toContain('pnpm flickr:import')
})
it('refuses --apply on a production target without the intent flag, before the key check', () => {
const result = run(['--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('FLICKR_IMPORT_CONFIRM=1')
expect(output(result)).not.toContain('FLICKR_API_KEY')
})
it('requires the declared TEQO_ENV once the intent flag is set', () => {
const result = run(['--apply'], { FLICKR_IMPORT_CONFIRM: '1' })
expect(result.status).toBe(1)
expect(output(result)).toContain('TEQO_ENV')
})
it('refuses a target database that does not match the declared environment', () => {
const result = run(['--apply'], { FLICKR_IMPORT_CONFIRM: '1', TEQO_ENV: 'staging' })
expect(result.status).toBe(1)
expect(output(result)).toContain('≠ "teqo_staging"')
})
it('refuses --apply without the complete S3_* set on a declared production target', () => {
const result = run(['--apply'], { FLICKR_IMPORT_CONFIRM: '1', TEQO_ENV: 'production' })
expect(result.status).toBe(1)
expect(output(result)).toContain('S3_BUCKET')
})
it('refuses an unknown argument before touching anything', () => {
const result = run(['--force'])
expect(result.status).toBe(1)
expect(output(result)).toContain('argumento desconhecido')
})
})

View file

@ -0,0 +1,351 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import {
archivePhotoReportStamp,
collectArchiveAlbums,
collectArchiveListing,
formatArchiveBytes,
formatArchiveInventory,
formatArchiveReport,
parseArchiveCliArgs,
planArchiveEntries,
summarizeArchiveInventory,
summarizeArchivePlan,
summarizeArchiveResults,
} from '../../scripts/lib/flickrPlan.mjs'
// C231 — the planner contract: pagination with video/malformed accounting,
// the photo→albums map, the plan entries (new/existing/fallback/invalid), the
// summaries and the human receipt lines. Flickr and Payload are always fakes.
const photoPage = (page: number, pages: number, total: number, photo: unknown[]) => ({
page,
pages,
total,
photo,
})
const fakeClient = ({
photoPages = {},
albumPages = {},
albumPhotoPages = {},
}: {
photoPages?: Record<number, unknown>
albumPages?: Record<number, unknown>
albumPhotoPages?: Record<string, Record<number, unknown>>
} = {}) => ({
listPhotosPage: vi.fn(async ({ page }: { page: number }) =>
photoPages[page] ? photoPages[page] : photoPage(page, 1, 0, []),
),
listPhotoSets: vi.fn(async ({ page }: { page: number }) =>
albumPages[page] ? albumPages[page] : { page, pages: 1, photoset: [] },
),
listPhotoSetPage: vi.fn(async ({ albumId, page }: { albumId: string; page: number }) =>
albumPhotoPages[albumId]?.[page]
? albumPhotoPages[albumId][page]
: { page, pages: 1, photo: [] },
),
})
const completePhoto = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
media: 'photo',
title: `Foto ${id}`,
url_o: `https://live.staticflickr.com/1/${id}_o.jpg`,
path_alias: 'depjorgesolla',
...overrides,
})
describe('collectArchiveListing (C231)', () => {
const photoPages = {
1: photoPage(1, 2, 4, [
completePhoto('1'),
{ id: '2', media: 'video' },
{ media: 'photo' },
completePhoto('3'),
]),
2: photoPage(2, 2, 4, [completePhoto('4')]),
}
it('walks every page, accounting videos and malformed items', async () => {
const client = fakeClient({ photoPages })
const listing = await collectArchiveListing({ client, userId: 'user' })
expect(listing.items.map((photo: { id: string }) => photo.id)).toEqual(['1', '3', '4'])
expect(listing.videos).toEqual(['2'])
expect(listing.malformed).toHaveLength(1)
expect(listing).toMatchObject({ apiTotal: 4, pages: 2, pagesFetched: 2 })
})
it('stops at the limit and starts at the requested page', async () => {
const limited = await collectArchiveListing({
client: fakeClient({ photoPages }),
userId: 'user',
limit: 2,
})
expect(limited.items.map((photo: { id: string }) => photo.id)).toEqual(['1', '3'])
expect(limited.pagesFetched).toBe(1)
const resumed = await collectArchiveListing({
client: fakeClient({ photoPages }),
userId: 'user',
startPage: 2,
})
expect(resumed.items.map((photo: { id: string }) => photo.id)).toEqual(['4'])
expect(resumed.apiTotal).toBe(4)
})
})
describe('collectArchiveAlbums (C231)', () => {
it('maps each photo to all its albums and accounts malformed albums', async () => {
const client = fakeClient({
albumPages: {
1: {
page: 1,
pages: 2,
photoset: [
{ id: 'a', title: { _content: 'Saúde' } },
{ id: 'b', title: 'Comícios' },
{ id: 'c', title: ' ' },
],
},
2: { page: 2, pages: 2, photoset: [{ id: 'b', title: 'Comícios' }] },
},
albumPhotoPages: {
a: { 1: { page: 1, pages: 1, photo: [{ id: '1' }, { id: '2' }] } },
b: { 1: { page: 1, pages: 1, photo: [{ id: '2' }] } },
},
})
const { albumsById, albumsByPhotoId, skippedAlbums } = await collectArchiveAlbums({
client,
userId: 'user',
})
expect(albumsById.size).toBe(2)
expect(albumsById.get('a')).toEqual({ albumId: 'a', title: 'Saúde' })
expect(albumsByPhotoId.get('1')).toEqual([{ albumId: 'a', title: 'Saúde' }])
expect(albumsByPhotoId.get('2')).toEqual([
{ albumId: 'a', title: 'Saúde' },
{ albumId: 'b', title: 'Comícios' },
])
expect(skippedAlbums).toEqual([{ albumId: 'c', reason: 'title' }])
})
})
describe('planArchiveEntries (C231)', () => {
it('classifies existing, new, fallback and invalid entries', async () => {
const albumsByPhotoId = new Map([['1', [{ albumId: 'a', title: 'Saúde' }]]])
const findExisting = vi.fn(async (flickrId: string) =>
flickrId === '3' ? { id: 99, filename: 'flickr-3.jpg', filesize: 10 } : null,
)
const entries = await planArchiveEntries({
items: [
completePhoto('1'),
completePhoto('2', { url_o: undefined }),
completePhoto('3'),
completePhoto('4', { media: 'screenshots' }),
],
albumsByPhotoId,
findExisting,
})
expect(entries.map((entry) => entry.status)).toEqual(['new', 'fallback', 'existing', 'invalid'])
expect(entries[0].record).toMatchObject({
flickrId: '1',
albums: [{ albumId: 'a', title: 'Saúde' }],
originalKind: 'original',
})
expect(entries[2]).toMatchObject({ existing: { id: 99 } })
expect(entries[3]).toMatchObject({ reason: 'not-a-photo' })
expect(findExisting).toHaveBeenCalledTimes(4)
})
it('classifies an already-archived photo as existing even without url_o', async () => {
const findExisting = vi.fn(async () => ({ id: 7, filename: 'flickr-7.jpg', filesize: 1 }))
const entries = await planArchiveEntries({
items: [completePhoto('7', { url_o: undefined })],
albumsByPhotoId: new Map(),
findExisting,
})
expect(entries).toEqual([expect.objectContaining({ flickrId: '7', status: 'existing' })])
expect(entries[0].record).toBeUndefined()
})
})
describe('summaries (C231)', () => {
it('summarizes the plan entries', () => {
expect(
summarizeArchivePlan([
{ status: 'new' },
{ status: 'new' },
{ status: 'existing' },
{ status: 'fallback' },
{ status: 'invalid' },
]),
).toEqual({ new: 2, existing: 1, fallback: 1, invalid: 1 })
})
it('summarizes the apply results with downloaded bytes and failures', () => {
expect(
summarizeArchiveResults([
{ flickrId: '1', status: 'created', bytes: 100 },
{ flickrId: '2', status: 'existing' },
{ flickrId: '3', status: 'failed', stage: 'download', error: 'HTTP 404' },
{ flickrId: '4', status: 'created', bytes: 50 },
]),
).toEqual({
created: 2,
existing: 1,
failed: 1,
bytes: 150,
failures: [{ flickrId: '3', stage: 'download', error: 'HTTP 404' }],
})
})
it('summarizes the stored inventory per album and per metadata gap', () => {
const inventory = summarizeArchiveInventory([
{
flickrId: '1',
filename: 'flickr-1.jpg',
filesize: 100,
takenAt: '2025-03-14 10:20:30',
geo: { latitude: -12, longitude: -38 },
exif: [{ tag: 'Make' }],
tags: [{ name: 'saude' }],
albums: [
{ albumId: 'a', title: 'Saúde' },
{ albumId: 'b', title: 'Comícios' },
],
},
{
flickrId: '2',
filename: null,
filesize: 50,
takenAt: null,
geo: { latitude: null, longitude: null },
exif: [],
tags: [],
albums: [{ albumId: 'a', title: 'Saúde' }],
},
])
expect(inventory).toMatchObject({
total: 2,
bytes: 150,
withTakenAt: 1,
withGeo: 1,
withExif: 1,
withTags: 1,
withAlbums: 2,
missingFilename: ['2'],
})
expect(inventory.albums).toEqual([
{ albumId: 'a', title: 'Saúde', count: 2 },
{ albumId: 'b', title: 'Comícios', count: 1 },
])
})
})
describe('receipt formatting (C231)', () => {
it('formats the plan and the apply reports without hiding failures', () => {
const base = {
apiTotal: 4,
collected: 3,
videos: 1,
malformed: [],
albums: 2,
durationMs: 1500,
}
const planLines = formatArchiveReport({
...base,
mode: 'plan',
plan: { new: 2, existing: 1, fallback: 0, invalid: 0 },
invalidEntries: [{ flickrId: '9', reason: 'id' }],
})
expect(planLines.join('\n')).toContain('plano (dry-run): novas: 2')
expect(planLines.join('\n')).toContain('inválida 9: id')
expect(planLines.join('\n')).toContain('tempo: 1.5s')
const applyLines = formatArchiveReport({
...base,
mode: 'apply',
summary: {
created: 1,
existing: 1,
failed: 1,
bytes: 2 * 1024 * 1024,
failures: [{ flickrId: '3', stage: 'download', error: 'HTTP 404' }],
},
})
const applyText = applyLines.join('\n')
expect(applyText).toContain('resultado: novas: 1')
expect(applyText).toContain('bytes baixados: 2.0 MiB')
expect(applyText).toContain('- 3 (download): HTTP 404')
})
it('formats the inventory and the byte label', () => {
const lines = formatArchiveInventory({
total: 2,
bytes: 1024 * 1024,
withTakenAt: 1,
withGeo: 1,
withExif: 0,
withTags: 2,
withAlbums: 1,
albums: [{ albumId: 'a', title: 'Saúde', count: 1 }],
missingFilename: ['2'],
})
const text = lines.join('\n')
expect(text).toContain('inventário: 2 foto(s) · 1.0 MiB')
expect(text).toContain('- Saúde (a): 1')
expect(text).toContain('sem arquivo armazenado: 2')
expect(formatArchiveBytes(2 * 1024 * 1024)).toBe('2.0 MiB')
})
it('stamps the report file name', () => {
expect(archivePhotoReportStamp('2026-09-26T06:20:39.123Z')).toBe('2026-09-26T06-20-39-123Z')
})
})
describe('parseArchiveCliArgs (C231)', () => {
it('defaults to the plan mode, page 1 and the standard receipt dir', () => {
expect(parseArchiveCliArgs([])).toEqual({
apply: false,
verify: false,
limit: null,
page: 1,
out: 'data/flickr',
help: false,
})
})
it('parses the flags, the canary and the receipt dir', () => {
expect(
parseArchiveCliArgs(['--apply', '--limit', '5', '--page', '3', '--out', 'data/fotos']),
).toMatchObject({ apply: true, verify: false, limit: 5, page: 3, out: 'data/fotos' })
expect(parseArchiveCliArgs(['--verify'])).toMatchObject({ verify: true, apply: false })
expect(parseArchiveCliArgs(['--help'])).toMatchObject({ help: true })
})
it('refuses exclusive modes, invalid numbers, escaping --out and unknown flags', () => {
expect(() => parseArchiveCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/)
expect(() => parseArchiveCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseArchiveCliArgs(['--limit', 'abc'])).toThrow(/--limit/)
expect(() => parseArchiveCliArgs(['--page', '0'])).toThrow(/--page/)
expect(() => parseArchiveCliArgs(['--out', 'data/../fora'])).toThrow(/escapar/)
expect(parseArchiveCliArgs(['--out', 'data/..final'])).toMatchObject({ out: 'data/..final' })
expect(() => parseArchiveCliArgs(['--force'])).toThrow(/argumento desconhecido/)
expect(() => parseArchiveCliArgs(['--limit'])).toThrow(/faltou valor/)
for (const argv of [['--apply', '--verify'], ['--force'], ['--limit']]) {
expect(() => parseArchiveCliArgs(argv)).toThrow(/pnpm flickr:import/)
}
})
})