mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
37bb239d1d
commit
aba3aa4816
29 changed files with 22928 additions and 85 deletions
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-26-c231.md
Normal file
1
docs/changelog/2026-09-26-c231.md
Normal 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`.
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
189
docs/plans/acervo-fotos-flickr-ingestao-impl.md
Normal file
189
docs/plans/acervo-fotos-flickr-ingestao-impl.md
Normal 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.**
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
392
scripts/ingest-flickr-archive.mjs
Normal file
392
scripts/ingest-flickr-archive.mjs
Normal 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))
|
||||
})
|
||||
|
|
@ -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
202
scripts/lib/flickrApi.mjs
Normal 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
390
scripts/lib/flickrPlan.mjs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
162
src/collections/ArchivePhoto.ts
Normal file
162
src/collections/ArchivePhoto.ts
Normal 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
279
src/lib/archivePhoto.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
}
|
||||
19676
src/migrations/20260926_062039_add_archive_photo.json
Normal file
19676
src/migrations/20260926_062039_add_archive_photo.json
Normal file
File diff suppressed because it is too large
Load diff
82
src/migrations/20260926_062039_add_archive_photo.ts
Normal file
82
src/migrations/20260926_062039_add_archive_photo.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
23
src/utilities/access/archivePhotos.ts
Normal file
23
src/utilities/access/archivePhotos.ts
Normal 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
|
||||
}
|
||||
|
|
@ -191,3 +191,5 @@ export {
|
|||
canReadContentPiece,
|
||||
canUpdateContentPiece,
|
||||
} from '@/utilities/access/contentPieces'
|
||||
|
||||
export { canReadArchivePhoto } from '@/utilities/access/archivePhotos'
|
||||
|
|
|
|||
141
src/utilities/flickr/archivePhotoIngest.ts
Normal file
141
src/utilities/flickr/archivePhotoIngest.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.')
|
||||
|
|
|
|||
9
tests/helpers/archivePhotoFixture.ts
Normal file
9
tests/helpers/archivePhotoFixture.ts
Normal 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',
|
||||
)
|
||||
227
tests/int/archivePhotoIngest.int.spec.ts
Normal file
227
tests/int/archivePhotoIngest.int.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
257
tests/unit/archivePhoto.unit.spec.ts
Normal file
257
tests/unit/archivePhoto.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
174
tests/unit/flickrApi.unit.spec.ts
Normal file
174
tests/unit/flickrApi.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
83
tests/unit/flickrImportCli.unit.spec.ts
Normal file
83
tests/unit/flickrImportCli.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
351
tests/unit/flickrPlan.unit.spec.ts
Normal file
351
tests/unit/flickrPlan.unit.spec.ts
Normal 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/)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue