From cd4942d9e201703dfb92a889ce57920336705d65 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 1 Oct 2026 05:27:28 -0300 Subject: [PATCH] =?UTF-8?q?feat(C242/C243):=20abre=20a=20busca=20por=20sel?= =?UTF-8?q?fie=20a=20qualquer=20visitante=20(escopo=20B)=20+=20se=C3=A7?= =?UTF-8?q?=C3=A3o=20na=20homepage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas. - faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade) - POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem - Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents - pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo - C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada - docs: planos C242/C243, AGENTS-public, runbook §C242, changelog Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde. --- AGENTS-public.md | 8 +- DESIGN.md | 2 + docs/changelog/2026-10-01-c242-c243.md | 1 + docs/ops/teqo-1313-deploy.md | 102 +- .../busca-fotos-por-selfie-ui-design.html | 140 +- docs/plans/busca-selfie-escopo-b-impl.md | 126 + docs/plans/busca-selfie-escopo-b.md | 92 + docs/plans/busca-selfie-secao-home-impl.md | 35 + .../busca-selfie-secao-home-ui-design.html | 481 + docs/plans/busca-selfie-secao-home.md | 76 + package.json | 3 +- scripts/enroll-face-subject.mjs | 224 - scripts/index-archive-faces.mjs | 70 +- scripts/lib/archivePublishPlan.mjs | 83 + scripts/lib/e2e-affected-manifest.mjs | 12 +- scripts/lib/faceConsentTexts.mjs | 52 + scripts/lib/faceEnrollPlan.mjs | 91 - scripts/lib/faceIndexPlan.mjs | 76 +- scripts/lib/test-affected-core.mjs | 3 +- scripts/publish-archive-photos.mjs | 213 + scripts/seed-face-consents.mjs | 159 + src/app/(frontend)/(home)/page.tsx | 10 +- src/app/(frontend)/api/fotos/selfie/route.ts | 74 +- src/app/(frontend)/fotos/encontre/page.tsx | 10 +- src/collections/ArchivePhoto.ts | 23 +- src/collections/ArchivePhotoFace.ts | 79 + src/collections/Consent.ts | 10 - src/collections/FaceSubject.ts | 135 - src/components/fotos/SelfieSearchEntry.tsx | 7 +- src/components/fotos/SelfieSearchFlow.tsx | 96 +- .../fotos/SelfieSearchHomeSection.tsx | 153 + src/components/fotos/archivePhotoClasses.ts | 2 +- src/lib/campaignConsentKeys.ts | 17 +- src/lib/faceSearch.ts | 99 +- ...0261001_040904_add_archive_photo_face.json | 20625 ++++++++++++++++ .../20261001_040904_add_archive_photo_face.ts | 77 + src/migrations/index.ts | 8 +- src/payload-types.ts | 62 +- src/payload.config.ts | 4 +- .../faceDescriptorIndex.ts} | 181 +- .../faceIndex/faceDescriptorReads.ts | 158 + .../faceSubjects/faceSubjectEnrollment.ts | 102 - .../faceSubjects/faceSubjectReads.ts | 136 - tests/e2e/frontendFotosSelfie.e2e.spec.ts | 109 +- tests/helpers/faceIntFixtures.ts | 57 +- tests/int/faceDescriptorIndex.int.spec.ts | 247 + tests/int/faceSearchApi.int.spec.ts | 113 +- tests/int/faceSubjectIndex.int.spec.ts | 249 - tests/unit/archivePublishPlan.unit.spec.ts | 77 + tests/unit/campaignHome.unit.spec.tsx | 50 +- tests/unit/faceCli.unit.spec.ts | 88 +- tests/unit/faceConsentTexts.unit.spec.ts | 60 + tests/unit/faceEnrollPlan.unit.spec.ts | 128 - tests/unit/faceIndexPlan.unit.spec.ts | 89 +- tests/unit/faceSearch.unit.spec.ts | 104 +- 55 files changed, 23591 insertions(+), 1897 deletions(-) create mode 100644 docs/changelog/2026-10-01-c242-c243.md create mode 100644 docs/plans/busca-selfie-escopo-b-impl.md create mode 100644 docs/plans/busca-selfie-escopo-b.md create mode 100644 docs/plans/busca-selfie-secao-home-impl.md create mode 100644 docs/plans/busca-selfie-secao-home-ui-design.html create mode 100644 docs/plans/busca-selfie-secao-home.md delete mode 100644 scripts/enroll-face-subject.mjs create mode 100644 scripts/lib/archivePublishPlan.mjs create mode 100644 scripts/lib/faceConsentTexts.mjs delete mode 100644 scripts/lib/faceEnrollPlan.mjs create mode 100644 scripts/publish-archive-photos.mjs create mode 100644 scripts/seed-face-consents.mjs create mode 100644 src/collections/ArchivePhotoFace.ts delete mode 100644 src/collections/FaceSubject.ts create mode 100644 src/components/fotos/SelfieSearchHomeSection.tsx create mode 100644 src/migrations/20261001_040904_add_archive_photo_face.json create mode 100644 src/migrations/20261001_040904_add_archive_photo_face.ts rename src/utilities/{faceSubjects/faceSubjectPhotoIndex.ts => faceIndex/faceDescriptorIndex.ts} (55%) create mode 100644 src/utilities/faceIndex/faceDescriptorReads.ts delete mode 100644 src/utilities/faceSubjects/faceSubjectEnrollment.ts delete mode 100644 src/utilities/faceSubjects/faceSubjectReads.ts create mode 100644 tests/int/faceDescriptorIndex.int.spec.ts delete mode 100644 tests/int/faceSubjectIndex.int.spec.ts create mode 100644 tests/unit/archivePublishPlan.unit.spec.ts create mode 100644 tests/unit/faceConsentTexts.unit.spec.ts delete mode 100644 tests/unit/faceEnrollPlan.unit.spec.ts diff --git a/AGENTS-public.md b/AGENTS-public.md index 63276a64..4c51c70f 100644 --- a/AGENTS-public.md +++ b/AGENTS-public.md @@ -6,13 +6,13 @@ The public album over the private archive (`archivePhoto`, C231/C232): **only `p The album global `photoAlbum` holds the two product knobs: `published` (kill switch — **only `false` closes**; an absent row means the schema default `true`) and `removalChannelUrl` (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo **requires a valid channel** (fail-closed guard in the global and in the collection); the album has no `loading.tsx` on purpose — a Suspense boundary would turn the closed album's `notFound()` into a `200`. The "Fotos" discovery link in `CampaignFooter` appears while there are approved photos (and always on `/fotos` itself). -## Selfie search (`/fotos/encontre`) — C234 +## Selfie search (`/fotos/encontre`) — C234/C242 -The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from `/fotos/modelos`, copied by `scripts/copy-face-vision-assets.mjs` in prebuild/predev/Docker) and posts only the 128-float descriptor to `POST /api/fotos/selfie` (`{ vector, intent: 'search' | 'leave-index' }`, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of **approved** photos (`{ ok, found, photos }`), never a score, a percentage or a third-party name — the view model (`src/lib/faceSearch.ts` `toFaceSearchPhotoView`) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when `photoAlbum.published === false` or `selfieSearchEnabled !== true`, 503 when the query Consent is missing. +The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from `/fotos/modelos`, copied by `scripts/copy-face-vision-assets.mjs` in prebuild/predev/Docker) and posts only the 128-float descriptor to `POST /api/fotos/selfie` (`{ vector, intent: 'search' | 'leave-index' }`, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of **approved** photos (`{ ok, found, photos }`), never a score, a percentage or a third-party name — the view model (`src/lib/faceSearch.ts` `toFaceSearchPhotoView`) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when `photoAlbum.published === false` or `selfieSearchEnabled !== true`, 503 when either Consent is missing. Since C243 the public homepage carries a conditional discovery section (`SelfieSearchHomeSection`) that only presents and links — no capture/engine there. -**The index is A/C by gate decision (PR #1370): only people who adhered and consented are searchable; there is no anonymous face index of the archive (that is option B, gated on DPIA + legal review).** Enrollment and indexing are ops-only CLIs (`pnpm faces:enroll`, `pnpm faces:index --apply|--verify`), run from the maintenance service with the same write guards as C231/C232 (`TEQO_ENV`, `FACE_*_CONFIRM=1`, mirrored `S3_*`). The batch computes descriptors transiently and persists only the photo↔subject links (`faceSubject.matchedPhotos`) plus the `archivePhoto.faces.checkedKey` marker (model + each subject's `enrolledAt`, so the batch's own writes never invalidate it). The CLI engine (`scripts/lib/faceEngine.mjs`) loads the same nets from `node_modules`; a model change invalidates the index (re-enroll + `--refresh`). +**The index is scope B (owner decision, 2026-10-01, supersedes the A/C gate of PR #1370): an anonymous descriptor index of the approved archive, so ANY visitor can find themselves.** The batch (`pnpm faces:index --apply|--verify`, maintenance service with the C231/C232 write guards) downloads each approved photo, detects faces with the same engine and persists identity-free rows in `archivePhotoFace` (photo + 128-float vector + model); `archivePhoto.faces.checkedKey` stores only the model, so a model change marks everything stale. There is no enrollment and no per-person consent: the `Consent` rows are the visitor's query consent and the public index notice. The rows die with the public status — the collection hooks purge them when a photo stops being `approved` or is deleted (plus the FK `ON DELETE CASCADE`) — and the answer always intersects with the C233 approved read. The CLI engine (`scripts/lib/faceEngine.mjs`) loads the same nets from `node_modules`. -Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`, and the two Consent keys (`busca-selfie-fotos` for the query, `busca-selfie-indice` for enrollment, in `src/lib/campaignConsentKeys.ts`) are never seeded — without the admin row with the exact key the page renders the closed state and the API refuses. Editing the enrollment text makes every subject ineligible until re-consent (`consentHash` snapshot). Opt-out is self-service ("Minha presença" → face match removes the vector and the links, status `removed`); photo takedown keeps using `photoAlbum.removalChannelUrl`. E2E builds stub the engine with `NEXT_PUBLIC_FACE_SEARCH_STUB=1` (`window.__faceSearchStub`); the real engine is exercised by the CLI canary and manually. Runbook: `docs/ops/teqo-1313-deploy.md` §C234. +Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`; the two Consent keys (`busca-selfie-fotos` query consent, `busca-selfie-indice` public index notice, in `src/lib/campaignConsentKeys.ts`) are provisioned by `pnpm seed:face-consents` from the texts versioned in `scripts/lib/faceConsentTexts.mjs` — without both admin rows the page renders the closed state and the API refuses. Opt-out is self-service ("Minha presença" → face match deletes the matching descriptors, so the face stops answering); photo takedown keeps using `photoAlbum.removalChannelUrl`. Opening the surface also requires approving the archive (`pnpm archive:publish`, bulk `draft → approved`, never touching `removed`). E2E builds stub the engine with `NEXT_PUBLIC_FACE_SEARCH_STUB=1` (`window.__faceSearchStub`); the real engine is exercised by the CLI canary and manually. Runbook: `docs/ops/teqo-1313-deploy.md` §C242. ## Posts & Tags (news / publications) diff --git a/DESIGN.md b/DESIGN.md index e78c5d61..1f6e371c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -294,6 +294,8 @@ This file is the living owner of Teqo's visual decisions — a working document, **Multi-media content gallery (C240, 2026-10-01).** A public piece with multiple media uses one stable stage plus position (“N de M”), previous/next controls, and a horizontally scrollable thumbnail strip; never shrink every thumbnail to force the whole set on screen. Download belongs to the selected media, while the single primary action shares the official source post — do not offer an ambiguous “download all” or make media-sharing compete with the source link. Partial arrival is an internal neutral processing state; the public gallery appears only when the complete set is ready. This keeps the action model honest across image-only, mixed video, and large carousels without inventing a second album product. +**Public light-surface focus separation (C243, 2026-10-01).** When the campaign-yellow focus outline sits on a cream, white, or yellow-tinted public surface, keep the 3px yellow signal but add a dark blue outer separator. Yellow alone does not separate reliably from these backgrounds; the second edge preserves the archive's established focus identity while making keyboard location unmistakable without turning blue into a competing action color. + How rules change: - **Decide, then update the owner.** When a better pattern wins — anti-slop review (`design-taste-frontend` / `gpt-taste`), a reference study (`awesome-design-md`), a redesign pass (`redesign-existing-projects`), or implementation judgment — update this file in the **same change** and say why. A pattern that only lives in code or in an agent's head is drift, not design. diff --git a/docs/changelog/2026-10-01-c242-c243.md b/docs/changelog/2026-10-01-c242-c243.md new file mode 100644 index 00000000..5503006d --- /dev/null +++ b/docs/changelog/2026-10-01-c242-c243.md @@ -0,0 +1 @@ +**C242/C243 — busca por selfie aberta a qualquer visitante (escopo B) + seção na homepage (2026-10-01):** o C234 entregou a busca **fechada** e no escopo A/C (só quem era inscrito pela assessoria se achava); por decisão do dono na sessão de 2026-10-01, com aval jurídico declarado, o escopo do gate (PR #1370) foi reaberto para **B**: o índice passa a ser **anônimo** — descritores 128-d extraídos dos rostos das fotos **aprovadas** do acervo, persistidos sem nome e sem vínculo com pessoa na collection nova `archivePhotoFace` (`admin.hidden`, admin-only), para que **qualquer visitante** encontre as próprias fotos. O enrollment A/C morre (collection `faceSubject`, `pnpm faces:enroll` e a adesão por pessoa foram removidos; migration `20261001_040904_add_archive_photo_face` cria a tabela nova com FK `ON DELETE CASCADE`, dropa a antiga — 0 linhas em produção — e o gerador reordenado à mão para o `CASCADE` do drop não engolir o `DROP CONSTRAINT`). O lote `pnpm faces:index` agora **persiste** os descritores (transação por foto substitui as linhas + marker `faces.checkedKey` = modelo); desaprovar/remover/deletar foto apaga os descritores nos hooks da collection (e o FK cobre SQL direto); o endpoint `POST /api/fotos/selfie` casa o vetor da consulta contra o índice carregado em memória (TTL 60 s, `Float32Array`, teto defensivo) e devolve só a interseção com o read aprovado do C233 — sem score, sem nome de terceiro; `leave-index` apaga os descritores que casarem (opt-out de fato). **Fail-closed mantido:** `selfieSearchEnabled` segue `false`, e os dois Consentimentos continuam obrigatórios — `busca-selfie-fotos` (consentimento da consulta, reescrito) e `busca-selfie-indice` (reclassificado como **aviso público do índice**, sem `consentHash`/elegibilidade por pessoa), com os textos versionados em `scripts/lib/faceConsentTexts.mjs` e provisionados por `pnpm seed:face-consents` (guarda `TEQO_ENV`/`FACE_CONSENT_SEED_CONFIRM`); a página exige os dois e mostra o aviso completo em `
`. Ops ganhou `pnpm archive:publish` (lote `draft → approved`, nunca toca `removed`, exige canal de remoção válido, guardas + recibo) para as 6.492 fotos nunca publicadas; o runbook §C242 documenta a abertura na ordem (canal → consentimentos → lote → indexação → flag) e o rollback. **C243:** a homepage pública ganha a seção condicional `SelfieSearchHomeSection` (design hi-fi `docs/plans/busca-selfie-secao-home-ui-design.html`, cenas 390/1280) entre a Central e a newsletter — só apresenta e linka (sem engine/captura/fotos/contadores na home), renderizada apenas com álbum publicado + flag + fotos aprovadas (fail-closed: fechada não deixa CTA morto). Copy do fluxo/entrada atualizada para o escopo B (o artefato `busca-fotos-por-selfie-ui-design.html` foi estendido pelo `designer`, que também atualizou `DESIGN.md` §7). Provas: unit 456 arquivos/5.050 testes verdes (novos: `findFaceDescriptorPhotoIds`, planos dos CLIs, textos de consentimento, gating da home), int full verde 109 arquivos/1.075 testes (índice/purges/endpoint/opt-out/rate limit), e2e `frontendFotosSelfie` atualizado (descritores em vez de subjects; opt-out por linhas; seção da home aberta/fechada). Pendente de ops (pós-deploy): canal de remoção, `seed:face-consents --apply`, `archive:publish --apply` + bust da tag, `faces:index --apply` e a flag. Planos: `docs/plans/busca-selfie-escopo-b.md`/`-impl.md` e `docs/plans/busca-selfie-secao-home.md`/`-impl.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 83e92b51..558b5864 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1452,64 +1452,76 @@ Rollback: a migration `add_archive_photo_catalog` é aditiva; o rollback funcion é limpar o grupo `catalog`/`curatedFields` no admin. Reexecutar nunca duplica nem sobrescreve curadoria. -## C234 — busca por selfie no álbum público +## C242 — busca por selfie aberta (índice anônimo do acervo) -A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) nasce -**fechada**: a flag `photoAlbum.selfieSearchEnabled` é `false` por default e o -fluxo só abre com o **Consent de consulta** (`busca-selfie-fotos`) configurado -no admin — sem ele a página mostra o estado fail-closed e a API recusa (503). -O índice é A/C (decisão do gate, PR #1370): só rostos de pessoas que aderiram -entram; não existe índice anônimo do acervo. A selfie nunca sai do aparelho do -visitante (o navegador envia só o vetor 128-d). +A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) cobre **qualquer +visitante**: o índice é anônimo (foto + descritor 128-d em `archivePhotoFace`), +sem nome, sem vínculo com pessoa e sem adesão prévia. A selfie nunca sai do +aparelho do visitante (o navegador envia só o vetor). A flag +`photoAlbum.selfieSearchEnabled` nasce `false` e é o kill switch imediato. -### Abertura (depois do aval jurídico/DPIA registrado) +### Abertura (ordem obrigatória) -1. No admin (`/admin`), crie os dois Consentimentos com as chaves exatas: - `busca-selfie-fotos` (consulta, texto mostrado no fluxo) e - `busca-selfie-indice` (adesão, assinado com a pessoa no enrollment). Sem os - dois, o fluxo não abre / ninguém é elegível. -2. Inscreva cada pessoa que aderiu (selfie com **um** rosto, com a pessoa - presente e o consentimento assinado). Rode no homeserver, no serviço de - manutenção do alvo (o engine roda local, sem endpoint externo): +1. **Canal de remoção** no admin (`/admin` → Configurações → Álbum de fotos): + `https://` ou `mailto:` válido. Sem ele o álbum não publica e o lote de + aprovação recusa. +2. **Consentimentos** (textos versionados em `scripts/lib/faceConsentTexts.mjs`): + no serviço de manutenção do alvo, com o env file do stack: ```bash cd ~/stack - # plano (dry-run: detecta o rosto e resolve o consentimento; não grava) - docker compose --profile maintenance run --rm -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:enroll --label "" --selfie /app/data/face-selfies/.jpg - # grava (exige TEQO_ENV=production e FACE_ENROLL_CONFIRM=1): - docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_ENROLL_CONFIRM=1 -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face-reports teqo-1313-migrate pnpm faces:enroll --label "" --selfie /app/data/face-selfies/.jpg --apply + docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e FACE_CONSENT_SEED_CONFIRM=1 \ + teqo-1313-migrate pnpm seed:face-consents --apply ``` -3. Indexe o acervo aprovado para os inscritos (canário primeiro): + (dry-run sem `--apply` mostra o estado; o guard exige `TEQO_ENV` casando o + banco exato.) +3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado): ```bash - docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:index --apply --limit 20 - # sem --limit processa todas as aprovadas; adicionar/reenrolar alguém - # muda a revisão e a próxima execução reprocessa tudo (~6,5k fotos). - docker compose --profile maintenance run --rm teqo-1313-migrate pnpm faces:index --verify # inventário read-only; sai 1 com pendências + docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \ + teqo-1313-migrate pnpm archive:publish --apply + # canário: --limit 20 ``` -4. Só então ligue `Busca por selfie` no global **Álbum de fotos** (admin). O - kill switch é imediato (sem deploy); desligar fecha a página (404) e a API. + Ao fim, buste a tag pública em um processo que a possui (o write do CLI não + revalida o servidor Next): + ```bash + curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \ + -H "x-revalidate-secret: $REVALIDATE_SECRET" + ``` +4. **Indexar** (horas; o engine roda local no maintenance, sem endpoint externo): + ```bash + docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 \ + -v /srv/face-reports:/app/data/face/reports \ + teqo-1313-migrate pnpm faces:index --apply --limit 20 # canário + docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 \ + -v /srv/face-reports:/app/data/face/reports \ + teqo-1313-migrate pnpm faces:index --apply # lote completo + docker compose --profile maintenance run --rm \ + -v /srv/face-reports:/app/data/face/reports \ + teqo-1313-migrate pnpm faces:index --verify # read-only; sai 1 com pendências + ``` + O resultado no site aparece em até ~60 s (cache em memória do índice). +5. **Ligar** `Busca por selfie` no global Álbum de fotos (admin). Sem deploy; + desligar fecha a página (404), a API e a seção da home imediatamente. -### Remoção e re-consentimento +### Remoção e privacidade -- **Sair do índice**: a própria pessoa usa "Minha presença" no fluxo (selfie - confere o vínculo; o vetor e os links são apagados e o status vira - `removed`). O pedido de **remoção de foto** continua no canal do álbum. -- **Texto do Consent editado**: os inscritos ficam inelegíveis até - re-consentimento (o `consentHash` do enrollment é comparado com o texto - vigente) — re-rode `faces:enroll --subject ` com a pessoa. -- **Troca de modelo do engine**: `FACE_SEARCH_MODEL` muda ⇒ o índice inteiro - fica stale; re-inscreva (`--subject`) e rode `faces:index --refresh`. -- **Rollback**: desligue a flag; a migration `add_face_subject` é aditiva - (tabela `face_subject`, colunas `faces_*` em `archive_photo` e - `selfie_search_enabled` no global). Nada do acervo público muda. +- **Sair do índice**: a própria pessoa usa “Minha presença” no fluxo (selfie + confere; os descritores correspondentes são apagados e o rosto deixa de ser + encontrado). O pedido de **remoção de foto** continua no canal do álbum. +- **Desaprovar/remover foto**: os descritores daquela foto são apagados pelos + hooks da collection (e o read aprovado nunca os mostra). +- **Rollback**: desligue a flag; a migration `add_archive_photo_face` é aditiva, + exceto o drop da `face_subject` (tabela vazia em produção). ### Guardas e recibos -`--apply` exige `TEQO_ENV` casando o banco exato, `FACE_ENROLL_CONFIRM=1` / -`FACE_INDEX_CONFIRM=1` fora do dev local e as quatro `S3_*` (mídia espelhada); -`ALLOW_REMOTE_DB` é recusado. Recibos JSON em `data/face/reports/` (nunca -levam vetor nem bytes de imagem). O engine carrega os mesmos modelos do -navegador de `node_modules`; a cópia same-origin para o browser é artefato de -build (`scripts/copy-face-vision-assets.mjs`). +`--apply` exige `TEQO_ENV` casando o banco exato e a flag de confirmação do +comando (`FACE_INDEX_CONFIRM`/`ARCHIVE_PUBLISH_CONFIRM`/`FACE_CONSENT_SEED_CONFIRM`), +mais as quatro `S3_*` quando a mídia for remota (indexação). Recibos JSON em +`data/face/reports/` e `data/archive/reports/` (nunca levam vetor nem bytes). ## C230-followup — importação do Instagram para a Central de Conteúdos diff --git a/docs/plans/busca-fotos-por-selfie-ui-design.html b/docs/plans/busca-fotos-por-selfie-ui-design.html index d2d9db47..a7181697 100644 --- a/docs/plans/busca-fotos-por-selfie-ui-design.html +++ b/docs/plans/busca-fotos-por-selfie-ui-design.html @@ -4,7 +4,7 @@ - Design — C234 · Busca de fotos por selfie + Design — C234/C242 · Busca de fotos por selfie + + + + +
+
+
+ C243 · gate hi-fi + Homepage pública · aberta + Sem selfie, engine, fotos ou contadores na home +
+

+ A home apresenta a busca; a experiência sensível começa só em /fotos/encontre +

+

+ Seção condicional, mobile-first, com uma ação dominante. Ela herda a gramática de bandas, + inset e hierarquia da home e usa o azul do álbum apenas para explicar proteção e controle. +

+
+ +
+

+ Cena 01 · 1280 +

+

+ Entre a Central de Conteúdos e a newsletter +

+

+ A banda creme interrompe o catálogo branco sem competir com a newsletter cinza. O texto + explica o suficiente para uma decisão informada; o aviso completo e a autorização ficam na + página da busca. +

+ +
+
+ + + +
+ + + +
+
+

+ Fotos da nossa caminhada +

+

+ Encontre você nas fotos +

+

+ Use uma selfie sua na página da busca para procurar, entre as fotos públicas + aprovadas, aquelas em que você aparece. +

+ +
+
+ +
+

+ A selfie não sai do seu aparelho. A busca cobre + o índice anônimo do acervo aprovado, e você pode retirar seu rosto quando + quiser. +

+
+ + +
+ +
+ +

+ Sua imagem fica com você. O controle também. +

+
    +
  • + + Processamento no aparelho + +
  • +
  • + + Só o acervo público aprovado + +
  • +
  • + + Saída do índice a qualquer momento + +
  • +
+
+
+
+ +
+

+ Apoie e acompanhe +

+

Receba as novidades da campanha

+
+
+
+
+ +
+

+ Cena 02 · 390 +

+

+ Mobile-first · uma ação dominante, sem simular captura +

+ +
+ + +
+ +
+

+ Fotos da nossa caminhada +

+

+ Encontre você nas fotos +

+

+ Use uma selfie sua na página da busca para procurar as fotos públicas aprovadas em + que você aparece. +

+ +
+
+
+ +
+
+

Sua selfie fica no aparelho

+

+ A selfie não sai deste aparelho. A busca cobre o acervo aprovado, e você pode + retirar seu rosto do índice quando quiser. +

+
+
+
+ + + Encontrar minhas fotos + + + + Ver o álbum + + +
+
+ +
+

+ Apoie e acompanhe +

+

+ Receba as novidades da campanha +

+
+
+
+ +
+

+ Contrato do port +

+

Estados e acessibilidade

+
    +
  • + Fechada: a seção inteira não é renderizada quando a flag, + o álbum publicado ou as fotos aprovadas não liberam a busca. Não há placeholder, aviso + nem CTA morto; Central de Conteúdos e newsletter se encostam pelo ritmo normal. +
  • +
  • + Hierarquia: o título da seção é h2; há um + único CTA preenchido. “Ver o álbum” permanece secundário, textual e com alvo mínimo de + 44 px. +
  • +
  • + Foco: ambos os links usam o foco amarelo de 3 px do álbum, + com offset de 3 px e separação azul externa para permanecer visível sobre creme, + vermelho e branco. +
  • +
  • + Movimento: só o CTA reage ao hover/press; em + prefers-reduced-motion: reduce, transição e deslocamento são removidos. +
  • +
+
+
+ + diff --git a/docs/plans/busca-selfie-secao-home.md b/docs/plans/busca-selfie-secao-home.md new file mode 100644 index 00000000..6201acb0 --- /dev/null +++ b/docs/plans/busca-selfie-secao-home.md @@ -0,0 +1,76 @@ +# C243 — Seção da busca por selfie na homepage pública + +Status: aprovado (decisão do dono na sessão, 2026-10-01) +Atualizado em: 2026-10-01 +Issue: — (worktree `work/43`, sem claim) +Priority: P2 +Impeccable: C — seção nova na homepage pública (descoberta da busca por selfie) +Design UI: docs/plans/busca-selfie-secao-home-ui-design.html +Appetite: ~0,5–1 dia eng — **uma seção da home que apresenta e leva à busca por selfie**, sem duplicar a superfície do álbum +Responsável: — + +## Intenção + +Com o C242 abrindo a busca por selfie a qualquer visitante, a homepage pública passa a ter uma porta para ela. Hoje a busca só é descoberta dentro de `/fotos` (banda da cena 01); o dono pediu (2026-10-01) **seção própria na homepage**, que aparece quando a busca está aberta e leva direto a `/fotos/encontre`. + +A seção é descoberta, não uma segunda busca: nada de captura de selfie na home (o engine pesado roda só na página da busca, como hoje). Copy e visual seguem a gramática das seções da home e o aviso de biometria/LGPD aparece de forma honesta. + +## Persona e fluxo + +- **Persona / contexto:** visitante na home da campanha, no celular, que viu Solla num evento e não sabe que existe um álbum pesquisável. +- **Job principal:** saber que dá para me encontrar nas fotos do mandato e chegar lá. +- **Fluxo desejado:** rola a home → encontra a seção "Encontre você nas fotos" com explicação curta (selfie não sai do aparelho; acervo aprovado; dá para sair do índice) → toca o CTA → chega em `/fotos/encontre`. +- **Anti-goals de produto:** não captura selfie na home; não lista pessoas; não promete "todas as fotos"; não vira carrossel de fotos com PII; não aparece fechada/quebrada. + +## Objetivo e aceite + +- A seção aparece na home **somente quando a busca está aberta** (flag `selfieSearchEnabled`, álbum publicado e fotos aprovadas) — fail-closed: nenhum CTA morto/404 enquanto fechada. +- O CTA leva a `/fotos/encontre`; a seção tem título, explicação curta e um link secundário para o álbum (`/fotos`). +- Mobile-first (390) e desktop (1280) fiéis ao design hi-fi aprovado; acessibilidade: heading na hierarquia da página, foco visível, contraste, `prefers-reduced-motion`. +- Nenhum texto que prometa identificação de terceiros ou score; o aviso de biometria é o mesmo contrato do C242. + +## Dados (intenção) + +- **Vou apresentar dados?** Não. +- **Forma:** N/A — sem números, sem contadores, sem "X fotos" (o acervo muda). + +## Dados da decisão (literais) + +- **Comportamento quando fechada — decisão do dono (2026-10-01):** "aparece quando aberta, mas já é pra abrir" → a seção é renderizada condicionada ao estado aberto e a abertura é feita na mesma entrega (ops do C242). +- **Sem captura na home:** o engine de face não é carregado na homepage (peso ~7 MB de modelos + wasm); a home só apresenta e linka. +- **Posição:** a definir no design hi-fi, entre as seções de conteúdo existentes (recomendação da engenharia: depois de `ContentPieceHomeSection`, antes da newsletter), sem quebrar o ritmo/altura das seções atuais. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/app/(frontend)/(home)/page.tsx` (composição + gating), componente novo em `src/components/fotos/`, classes/tokens `campaign-section-*` de `styles.css` e os padrões de seção da home (`JingleHomeSection`, `ContentPieceHomeSection`, `CampaignCardsSection`). +- **Precedente a olhar:** `SelfieSearchEntry.tsx` (banda do C234 em `/fotos`), `ContentPieceHomeSection.tsx` (seção condicional), `hasPublishedArchivePhotos` (flag cacheada) + `getCachedGlobal('photoAlbum')` (kill switches com tag própria). +- **Risco de acoplamento:** a home usa reads cacheados (ISR/tag); a seção não pode introduzir leitura live de Consent (perda de estaticidade). Gatear por flag global + fotos aprovadas (ambos cacheados e revalidados no admin); misconfiguração de Consent cai no estado fechado da página de destino, nunca em 404. + +## Dependências + +- Duras: C242 (busca aberta a qualquer visitante) — a seção só faz sentido com ela. +- Soft: nenhuma. + +## Fora de escopo + +- Captura/engine na home; carrossel de fotos; contadores; personalização por visitante. +- Mudar a `/fotos` ou o fluxo de `/fotos/encontre` além da copy/porta do C242. + +## Rabbit holes de produto + +- **"Só completar" com prévia de fotos na home.** **Corte:** a home não renderiza fotos do acervo (PII + peso); só a banda de convite. +- **"Só completar" com captura inline.** **Corte:** engine só em `/fotos/encontre`. + +## Questões em aberto (produto) + +- Nenhuma bloqueante; posição final e composição ficam no design hi-fi (gate visual). + +## Referências + +- `docs/plans/busca-fotos-por-selfie.md` (C234 — artefato de design a estender) +- `docs/plans/busca-selfie-escopo-b.md` (C242 — dono do estado "aberta") +- `src/components/fotos/SelfieSearchEntry.tsx` · `src/app/(frontend)/(home)/page.tsx` + +## Self-score (shaping) + +5/5 — (1) fatia = um outcome verificável (visitante descobre e chega à busca quando aberta; fechada não aparece); (2) appetite pequeno comporta design + componente + testes; (3) persona/job/aceite verificáveis; (4) direção no codebase com precedentes; (5) decisões duras (comportamento e captura) tomadas e registradas. diff --git a/package.json b/package.json index e1fea77b..01dd9ce1 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,9 @@ "content:instagram:withdraw": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/withdraw-instagram-content.mjs", "content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-instagram-content.mjs", "archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs", - "faces:enroll": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-subject.mjs", "faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs", + "archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs", + "seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.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", diff --git a/scripts/enroll-face-subject.mjs b/scripts/enroll-face-subject.mjs deleted file mode 100644 index fdad1416..00000000 --- a/scripts/enroll-face-subject.mjs +++ /dev/null @@ -1,224 +0,0 @@ -/** - * C234 — enrolls one person in the selfie-search index (A/C scope of the gate, - * PR #1370): the assessoria runs this WITH the person, after the signed - * adherence consent, and the row is the only place a consented descriptor - * exists. The selfie is read from disk, the descriptor is computed locally by - * the same face-api nets the browser runs (only ONE face is accepted) and the - * image is never stored or uploaded anywhere. - * - * Modes: - * pnpm faces:enroll --label "" --selfie - * plan/dry-run (default) — detects the face, - * resolves the consent and reports; no write - * pnpm faces:enroll ... --apply writes the faceSubject row; requires - * FACE_ENROLL_CONFIRM=1 outside local dev and - * the declared TEQO_ENV matching the database - * - * Options: --subject (re-enrollment: replaces the descriptor, advances - * `enrolledAt` and clears the derived links), --out (receipts, default - * `data/face`), --help. - * - * Guards: fails closed when the index Consent (stable key) is not configured, - * when the selfie has 0 or more than 1 detectable face, and on a non-local - * target without the intent flag + declared environment. The receipt never - * carries the descriptor nor the image. - */ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' - -import { getPayload } from 'payload' - -import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts' -import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts' - -import { - TEQO_ENV_DATABASE_BY_ENV, - assertEnvironmentDatabaseTarget, - assertWriteConfirm, - databaseTarget, - dieWithLabel, - loadCliEnv, - writeRepoFile, -} from './lib/cli.mjs' -import { createNodeFaceEngine } from './lib/faceEngine.mjs' -import { - FACE_ENROLL_DEFAULT_OUT_DIR, - faceEnrollReportStamp, - formatFaceEnrollReport, - parseFaceEnrollCliArgs, -} from './lib/faceEnrollPlan.mjs' - -loadCliEnv() - -const die = dieWithLabel('faces:enroll') - -const WRITE_CONFIRM_FLAG = 'FACE_ENROLL_CONFIRM' - -const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) - .map(([environment, database]) => `${environment} (${database})`) - .join(' | ') - -const HELP = ` -Uso: pnpm faces:enroll --label "" --selfie [opções] - -Inscreve uma pessoa no índice da busca por selfie (/fotos/encontre). A selfie é -lida do disco e processada localmente (mesmos modelos do navegador): exatamente -1 rosto é exigido. O vetor fica no servidor; a imagem nunca é guardada. - -Modos: - (sem flag) plano/dry-run — detecta o rosto e resolve o consentimento; sem escrita - --apply grava a pessoa no índice (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) - -Opções: - --label nome interno da pessoa (nunca aparece no site) - --selfie foto com um único rosto - --subject re-enrollment: substitui o vetor e limpa os vínculos derivados - --out diretório dos recibos (default ${FACE_ENROLL_DEFAULT_OUT_DIR}) - --help esta ajuda - -Ambiente: - DATABASE_URL alvo (obrigatório em todos os modos) - ---apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} -casando o banco exato. O consentimento de adesão (chave ${FACE_INDEX_CONSENT_KEY}) precisa -existir no admin; sem ele o fluxo falha fechado. -` - -const writeReport = async (options, report, suffix = '') => { - const stamp = faceEnrollReportStamp(report.runAt) - const relativePath = join(options.out, 'reports', `face-enroll-${stamp}${suffix}.json`) - const body = JSON.stringify(report, null, 2) - await writeRepoFile({ - label: 'faces:enroll', - root: process.cwd(), - relativePath, - body, - }) - return join(process.cwd(), relativePath) -} - -async function main() { - const options = parseFaceEnrollCliArgs(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' : 'plan' - const runAt = new Date().toISOString() - const startedAt = Date.now() - console.log(`[faces:enroll] alvo: ${databaseTarget()} | modo: ${mode}`) - - if (mode === 'apply') { - assertWriteConfirm({ - label: 'faces:enroll', - flag: WRITE_CONFIRM_FLAG, - command: 'pnpm faces:enroll --label "" --selfie --apply', - }) - assertEnvironmentDatabaseTarget() - } - - try { - // Readability probe only: the bytes are decoded by `prepareFaceImage` - // below; this gives the operator a friendly refusal before the engine. - await readFile(options.selfie) - } catch (error) { - die(`não foi possível ler a selfie ${options.selfie}: ${error?.message ?? error}`) - } - - const engine = await createNodeFaceEngine(process.cwd()).catch((error) => { - die(error.message) - }) - - const { prepareFaceImage } = - await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts') - let descriptors - try { - descriptors = await engine.detectPixels(await prepareFaceImage(options.selfie)) - } catch (error) { - die(`engine falhou ao processar a selfie: ${error?.message ?? error}`) - } - - const baseReport = { - runAt, - mode, - target: databaseTarget(), - model: FACE_SEARCH_MODEL, - label: options.label.trim(), - subjectId: options.subject, - subjectAction: null, - faceCount: descriptors.length, - allowed: descriptors.length === 1, - reason: - descriptors.length === 1 - ? null - : descriptors.length === 0 - ? 'nenhum rosto detectado — use uma selfie nítida, de frente e bem iluminada' - : 'mais de um rosto na selfie — use uma foto com apenas uma pessoa', - consentKey: FACE_INDEX_CONSENT_KEY, - consentHash: null, - durationMs: 0, - } - - if (!baseReport.allowed) { - const report = { ...baseReport, durationMs: Date.now() - startedAt } - const reportPath = await writeReport(options, report) - for (const line of formatFaceEnrollReport(report)) console.log(line) - console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`) - die('enrollment recusado — detalhe no recibo acima.') - } - - const config = (await import('../src/payload.config.ts')).default - const payload = await getPayload({ config }) - const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts') - const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY) - if (!consent) { - const report = { ...baseReport, durationMs: Date.now() - startedAt } - await writeReport(options, report) - for (const line of formatFaceEnrollReport(report)) console.log(line) - die( - `consentimento de adesão não configurado (chave ${FACE_INDEX_CONSENT_KEY}) — o fluxo falha fechado.`, - ) - } - - const consentHash = consent.contentHash - if (mode === 'plan') { - const report = { - ...baseReport, - consentHash, - durationMs: Date.now() - startedAt, - } - const reportPath = await writeReport(options, report) - for (const line of formatFaceEnrollReport(report)) console.log(line) - console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`) - process.exit(0) - } - - const { enrollFaceSubject } = - await import('../src/utilities/faceSubjects/faceSubjectEnrollment.ts') - const enrolled = await enrollFaceSubject({ - payload, - label: baseReport.label, - descriptor: descriptors[0], - consent: { id: consent.id, contentHash: consentHash }, - subjectId: options.subject, - }) - - const report = { - ...baseReport, - subjectId: enrolled.id, - subjectAction: enrolled.created ? 'created' : 'updated', - consentHash, - durationMs: Date.now() - startedAt, - } - const reportPath = await writeReport(options, report) - for (const line of formatFaceEnrollReport(report)) console.log(line) - console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`) - process.exit(0) -} - -main().catch((error) => { - die(error?.message || String(error)) -}) diff --git a/scripts/index-archive-faces.mjs b/scripts/index-archive-faces.mjs index afc606c6..52abddda 100644 --- a/scripts/index-archive-faces.mjs +++ b/scripts/index-archive-faces.mjs @@ -1,11 +1,9 @@ /** - * C234 — indexes the approved photo archive for the selfie search: each photo + * C242 — indexes the approved photo archive for the selfie search: each photo * is downloaded from the private storage, prepared small and handed to the SAME - * face-api engine the browser runs; the descriptors are compared against the - * enrolled subjects and thrown away — only the photo↔subject links and the - * photo's revision marker are persisted (A/C scope of the gate, PR #1370: no - * anonymous face index anywhere). Adding/re-enrolling a subject changes the - * revision, so the next run reprocesses every approved photo. + * face-api engine the browser runs; the descriptors of every detected face are + * PERSISTED as anonymous rows in `archivePhotoFace` (scope B, decision of + * 2026-10-01: any visitor can find themselves; no identity, no score). * * Modes: * pnpm faces:index plan/dry-run (default) — the queue; no engine, no write @@ -13,7 +11,7 @@ * FACE_INDEX_CONFIRM=1 outside local dev, the * declared TEQO_ENV and the complete S3_* set * when the target media is remote - * pnpm faces:index --verify read-only inventory (indexed/stale/consent) + * pnpm faces:index --verify read-only inventory (indexed/stale/descriptors) * * Options: --limit (canary), --refresh (ignores the marker), --out * (receipts, default `data/face`), --help. @@ -25,7 +23,6 @@ import { join } from 'node:path' import { getPayload } from 'payload' -import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts' import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts' import { @@ -63,14 +60,14 @@ const HELP = ` Uso: pnpm faces:index [opções] Indexa as fotos APROVADAS do acervo para a busca por selfie: por foto, detecta -os rostos com os mesmos modelos do navegador, compara com os descritores das -pessoas inscritas e grava só os vínculos (foto ↔ pessoa) + a revisão da foto. -Os descritores dos rostos do acervo são descartados — não existe índice anônimo. +os rostos com os mesmos modelos do navegador e grava os descritores como linhas +anônimas (foto + vetor + modelo) em archivePhotoFace. Não há nome nem vínculo +com pessoa; a busca responde só com fotos. Modos (mutuamente exclusivos; o default é o plano): (sem flag) plano/dry-run — fila e revisão; sem engine nem escrita --apply processa a fila (engine local + escrita transacional) - --verify inventário read-only (indexadas/desatualizadas/consentimento) + --verify inventário read-only (indexadas/desatualizadas/rostos) Opções: --limit --apply: processa no máximo n fotos (canário) @@ -130,24 +127,22 @@ async function main() { const config = (await import('../src/payload.config.ts')).default const { listArchivePhotoFaceIndexQueue, indexArchivePhotoFaces } = - await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts') - const { listFaceSubjects } = await import('../src/utilities/faceSubjects/faceSubjectReads.ts') - const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts') + await import('../src/utilities/faceIndex/faceDescriptorIndex.ts') const payload = await getPayload({ config }) const startedAt = Date.now() if (mode === 'verify') { - const [queue, subjects, consent] = await Promise.all([ + const [queue, descriptors] = await Promise.all([ listArchivePhotoFaceIndexQueue({ payload }), - listFaceSubjects(payload), - getConsentByKey(payload, FACE_INDEX_CONSENT_KEY), + payload.count({ + collection: 'archivePhotoFace', + where: { model: { equals: FACE_SEARCH_MODEL } }, + // Intentional bypass: the batch CLI is a trusted operator with no session. + overrideAccess: true, + }), ]) - const inventory = summarizeFaceIndexInventory({ - queue, - subjects, - currentConsentHash: consent?.contentHash ?? null, - }) + const inventory = summarizeFaceIndexInventory({ queue, descriptors: descriptors.totalDocs }) const report = { runAt, mode, @@ -160,21 +155,9 @@ async function main() { for (const line of formatFaceIndexInventory(inventory, FACE_SEARCH_MODEL)) console.log(line) console.log(`\n[faces:index] recibo JSON: ${reportPath}`) - const problems = [] - if (!consent) { - problems.push( - `consentimento de adesão ausente (chave ${FACE_INDEX_CONSENT_KEY}) — a busca responde vazio para todos`, - ) - } - if (inventory.staleConsentSubjects > 0) { - problems.push( - `${inventory.staleConsentSubjects} sujeito(s) com consentimento vencido — re-consentir e re-enrollar`, - ) - } if (inventory.stale > 0) { - problems.push(`${inventory.stale} foto(s) desatualizada(s) — rode o --apply`) + die(`${inventory.stale} foto(s) desatualizada(s) — rode o --apply`) } - if (problems.length > 0) die(problems.join('; ')) process.exit(0) } @@ -194,10 +177,9 @@ async function main() { indexKey: queue.indexKey, totalApproved: queue.totalApproved, stale: queue.stale, - eligibleSubjects: queue.eligibleSubjects, items: queue.items.length, }, - summary: { indexed: 0, failed: 0, linkedPhotos: 0, linksBySubject: [], failures: [] }, + summary: { indexed: 0, failed: 0, descriptors: 0, failures: [] }, preview: queue.items.slice(0, options.limit ?? 20).map((item) => ({ photoId: item.id })), durationMs: Date.now() - startedAt, } @@ -207,10 +189,9 @@ async function main() { process.exit(0) } - if (queue.eligibleSubjects === 0) { - die( - 'nenhuma pessoa elegível no índice — rode o faces:enroll antes (o lote não tem o que procurar).', - ) + if (queue.items.length === 0) { + console.log('[faces:index] nada a fazer — todas as fotos aprovadas estão na revisão atual.') + process.exit(0) } const engine = await createNodeFaceEngine(process.cwd()).catch((error) => { @@ -229,8 +210,8 @@ async function main() { const detail = result.status === 'failed' ? ` [${result.stage}]: ${result.error}` - : result.matchedSubjects.length > 0 - ? ` → ${result.matchedSubjects.length} vínculo(s)` + : result.descriptorCount > 0 + ? ` → ${result.descriptorCount} rosto(s)` : '' console.log(`[faces:index] ${result.status}${detail} foto ${item.id}`) } @@ -245,7 +226,6 @@ async function main() { indexKey: queue.indexKey, totalApproved: queue.totalApproved, stale: queue.stale, - eligibleSubjects: queue.eligibleSubjects, items: queue.items.length, }, summary, diff --git a/scripts/lib/archivePublishPlan.mjs b/scripts/lib/archivePublishPlan.mjs new file mode 100644 index 00000000..0f3a42ee --- /dev/null +++ b/scripts/lib/archivePublishPlan.mjs @@ -0,0 +1,83 @@ +/** + * C242 — pure planning/reporting of `pnpm archive:publish`: the argv parser, + * the run summary and the human lines + JSON receipt. No I/O of its own, so the + * unit spec drives it with fakes. + */ + +const USAGE = 'uso: `pnpm archive:publish [--apply] [--limit ] [--out ]`' + +export const ARCHIVE_PUBLISH_DEFAULT_OUT_DIR = 'data/archive' + +export const archivePublishReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-') + +/** + * @param {string[]} [argv] + * @returns {{ apply: boolean, limit: number | null, out: string, help: boolean }} + */ +export const parseArchivePublishCliArgs = (argv = process.argv.slice(2)) => { + const options = { apply: false, limit: null, out: ARCHIVE_PUBLISH_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 === '--limit') options.limit = Number(value()) + else if (arg === '--out') options.out = value() + else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`) + } + + if (options.help) return options + if (options.limit !== null && (!Number.isSafeInteger(options.limit) || options.limit < 1)) { + throw new Error('--limit deve ser >= 1.') + } + if (options.out.split(/[\\/]/).includes('..')) { + throw new Error('--out não pode escapar do diretório do repo (sem "..").') + } + return options +} + +/** + * @param {Array<{ photoId: number, status: string, error?: string | null }>} results + */ +export const summarizeArchivePublishResults = (results) => { + const failures = [] + let approved = 0 + let failed = 0 + + for (const result of results) { + if (result.status === 'approved') approved += 1 + else { + failed += 1 + failures.push({ photoId: result.photoId, error: result.error ?? null }) + } + } + + return { approved, failed, failures } +} + +export const formatArchivePublishReport = (report) => { + const lines = [ + `[archive:publish] modo: ${report.mode} | alvo: ${report.target}`, + `[archive:publish] drafts elegíveis: ${report.queue.totalDrafts}`, + ] + + if (report.mode !== 'plan') { + lines.push( + `[archive:publish] aprovadas: ${report.summary.approved} | falharam: ${report.summary.failed}`, + ) + } + + for (const failure of report.summary?.failures ?? []) { + lines.push(`[archive:publish] ✗ foto ${failure.photoId}: ${failure.error}`) + } + + return lines +} diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 477e9d39..697d5107 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -228,15 +228,15 @@ export const E2E_AFFECTED_MANIFEST = [ specs: ['frontendFotos', 'frontendFotosSelfie'], }, { - // C234 — the selfie search: the `/fotos/encontre` page (already matched by - // the C233 prefix above), the anonymous descriptor endpoint, the consented - // subjects and the bounded-body/rate-limit owners it shares. The migration - // makes every PR of this delivery high-risk (curated only). + // C242 — the selfie search: the `/fotos/encontre` page (already matched by + // the C233 prefix above), the anonymous descriptor endpoint, the anonymous + // photo-face index and the bounded-body/rate-limit owners it shares. The + // migration makes every PR of this delivery high-risk (curated only). prefixes: [ 'src/app/(frontend)/api/fotos', 'src/lib/faceSearch.ts', - 'src/utilities/faceSubjects', - 'src/collections/FaceSubject.ts', + 'src/utilities/faceIndex', + 'src/collections/ArchivePhotoFace.ts', 'src/collections/Consent.ts', 'src/utilities/campaignConsent.ts', 'src/lib/campaignConsentKeys.ts', diff --git a/scripts/lib/faceConsentTexts.mjs b/scripts/lib/faceConsentTexts.mjs new file mode 100644 index 00000000..b573fc99 --- /dev/null +++ b/scripts/lib/faceConsentTexts.mjs @@ -0,0 +1,52 @@ +/** + * C242 — the legal texts the selfie search resolves by stable key, versioned in + * the repo so the delivery that opens the surface owns what the visitor reads: + * + * - `busca-selfie-fotos` is the QUERY consent: authorizes the use of the + * visitor's own selfie/biometrics for this one search, on device. + * - `busca-selfie-indice` is the PUBLIC NOTICE of the anonymous archive index + * (scope B): transparency about the descriptor index over approved photos + * and the self-service opt-out. It is not an enrollment consent — nobody is + * individually enrolled. + * + * Both are written to the `consent` collection by `pnpm seed:face-consents` + * (create-if-missing, idempotent, guarded); editing a text here and re-running + * updates the row (versioned by Payload). + */ + +/** Builds the Lexical root of a Consent text from plain paragraphs. */ +export const buildConsentRichText = (paragraphs) => ({ + root: { + type: 'root', + children: paragraphs.map((text) => ({ + type: 'paragraph', + children: [{ type: 'text', text, version: 1 }], + direction: null, + format: '', + indent: 0, + version: 1, + })), + direction: null, + format: '', + indent: 0, + version: 1, + }, +}) + +export const FACE_SEARCH_CONSENT_PARAGRAPHS = [ + 'Autorizo, de forma livre, informada e específica, o uso da minha selfie e dos dados biométricos faciais dela extraídos exclusivamente para esta consulta: procurar, entre as fotos públicas aprovadas do acervo do mandato de Jorge Solla, aquelas em que eu apareço.', + 'O reconhecimento acontece no meu próprio aparelho. A imagem da selfie não é enviada nem armazenada; ao servidor é enviado apenas o vetor matemático necessário para a comparação, que é descartado ao fim da consulta e não fica guardado.', + 'Estou ciente de que o resultado não mostra nomes de terceiros e não informa grau de semelhança. A busca pode não encontrar fotos, e posso interrompê-la a qualquer momento.', + 'A base legal é o consentimento específico para dados pessoais sensíveis (art. 11, I, da LGPD). Posso retirar este consentimento a qualquer momento, sem custo, pelo próprio fluxo ou pelos canais do mandato — a retirada não afeta o que já foi realizado.', + 'Controlador: campanha Jorge Solla 1313. Mais detalhes na Política de Privacidade do site.', +] + +export const FACE_INDEX_CONSENT_PARAGRAPHS = [ + 'Aviso público sobre o índice de fotos: as fotos públicas aprovadas do acervo podem conter rostos de várias pessoas. Para que cada pessoa encontre as próprias fotos pela busca por selfie, o sistema mantém um índice biométrico anônimo — vetores matemáticos extraídos dos rostos presentes nessas fotos, sem nome, sem vínculo com cadastro de pessoas e sem qualquer identificação de quem aparece.', + 'O índice serve apenas para localizar as fotos da própria pessoa que faz a busca. Ele nunca é usado para identificar, nomear ou listar terceiros, e nunca cruza os dados biométricos com contatos, lideranças ou apoiadores.', + 'Qualquer pessoa pode retirar seu rosto do índice a qualquer momento, sem custo: use a opção “Minha presença” na página da busca e siga as instruções. A retirada apaga do índice as referências biométricas correspondentes, e o rosto deixa de ser encontrado.', + 'Para pedir a remoção de uma foto específica, use o canal indicado no álbum público. Fotos que não estejam aprovadas para o público não fazem parte do índice.', +] + +export const FACE_SEARCH_CONSENT_TEXT = buildConsentRichText(FACE_SEARCH_CONSENT_PARAGRAPHS) +export const FACE_INDEX_CONSENT_TEXT = buildConsentRichText(FACE_INDEX_CONSENT_PARAGRAPHS) diff --git a/scripts/lib/faceEnrollPlan.mjs b/scripts/lib/faceEnrollPlan.mjs deleted file mode 100644 index 8866fbdd..00000000 --- a/scripts/lib/faceEnrollPlan.mjs +++ /dev/null @@ -1,91 +0,0 @@ -/** - * C234 — pure planning/reporting of `pnpm faces:enroll`: the argv parser and - * the human lines + JSON receipt of the enrollment. No I/O of its own — the - * engine, the Consent resolution and the Payload write arrive as parameters, - * so the unit spec drives everything with fakes. - */ - -const USAGE = - 'uso: `pnpm faces:enroll --label "" --selfie [--subject ] [--apply]`' - -export const FACE_ENROLL_DEFAULT_OUT_DIR = 'data/face' - -export const faceEnrollReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-') - -/** - * `pnpm faces:enroll` 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; - * `--label` and `--selfie` are required in both modes so the plan shows the - * real operation, not an abstract one. - * - * @param {string[]} [argv] - * @returns {{ apply: boolean, label: string | null, selfie: string | null, subject: number | null, out: string, help: boolean }} - */ -export const parseFaceEnrollCliArgs = (argv = process.argv.slice(2)) => { - const options = { - apply: false, - label: null, - selfie: null, - subject: null, - out: FACE_ENROLL_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 === '--label') options.label = value() - else if (arg === '--selfie') options.selfie = value() - else if (arg === '--subject') options.subject = Number(value()) - else if (arg === '--out') options.out = value() - else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`) - } - - if (options.help) return options - if (!options.label?.trim()) throw new Error(`--label é obrigatório — ${USAGE}.`) - if (!options.selfie?.trim()) throw new Error(`--selfie é obrigatório — ${USAGE}.`) - if (options.subject !== null && (!Number.isSafeInteger(options.subject) || options.subject < 1)) { - throw new Error('--subject deve ser um id >= 1.') - } - if (options.out.split(/[\\/]/).includes('..')) { - throw new Error('--out não pode escapar do diretório do repo (sem "..").') - } - return options -} - -/** - * The receipt + human lines of one enrollment: what was detected (face count — - * never the descriptor), the subject row and the consent snapshot. A refusal - * (0 or >1 faces) is a report with `allowed: false` and its reason. - * - * @param {{ runAt: string, mode: string, target: string, model: string, label: string, subjectId: number | null, subjectAction: 'created' | 'updated' | null, faceCount: number, allowed: boolean, reason?: string | null, consentKey: string, consentHash: string | null, durationMs: number }} report - */ -export const formatFaceEnrollReport = (report) => { - const lines = [ - `[faces:enroll] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`, - `[faces:enroll] selfie: ${report.faceCount} rosto(s) detectado(s)`, - ] - if (!report.allowed) { - lines.push(`[faces:enroll] RECUSADO: ${report.reason ?? 'selfie fora do contrato'}`) - return lines - } - if (report.subjectAction === null) { - lines.push(`[faces:enroll] plano: ${report.label} (nada gravado; confirme com --apply)`) - return lines - } - lines.push( - `[faces:enroll] ${report.subjectAction === 'created' ? 'criada' : 'atualizada'}: #${report.subjectId} (${report.label})`, - `[faces:enroll] consentimento: ${report.consentKey} · hash ${report.consentHash ?? '(ausente)'}`, - `[faces:enroll] próximo passo: pnpm faces:index --apply (reprocessa as fotos aprovadas)`, - ) - return lines -} diff --git a/scripts/lib/faceIndexPlan.mjs b/scripts/lib/faceIndexPlan.mjs index 24961d36..cbd9a7fc 100644 --- a/scripts/lib/faceIndexPlan.mjs +++ b/scripts/lib/faceIndexPlan.mjs @@ -1,7 +1,7 @@ /** - * C234 — pure planning/reporting of `pnpm faces:index`: the argv parser, the + * C242 — pure planning/reporting of `pnpm faces:index`: the argv parser, the * run summary, the `--verify` inventory and the human lines + JSON receipt. No - * I/O of its own — the queue, the subjects and the results arrive as + * I/O of its own — the queue, the descriptor counts and the results arrive as * parameters, so the unit spec drives everything with fakes. */ @@ -61,24 +61,21 @@ export const parseFaceIndexCliArgs = (argv = process.argv.slice(2)) => { } /** - * Aggregates one apply run: photos indexed (and how many subjects each linked), + * Aggregates one apply run: photos indexed (and how many faces each stored), * photos refused by the engine/IO with stage+reason. * - * @param {Array<{ photoId: number, status: string, matchedSubjects?: number[], stage?: string | null, error?: string | null }>} results + * @param {Array<{ photoId: number, status: string, descriptorCount?: number, stage?: string | null, error?: string | null }>} results */ export const summarizeFaceIndexResults = (results) => { - const linkedBySubject = new Map() const failures = [] let indexed = 0 let failed = 0 - let linkedPhotos = 0 + let descriptors = 0 for (const result of results) { if (result.status === 'indexed') { indexed += 1 - const matched = result.matchedSubjects ?? [] - if (matched.length > 0) linkedPhotos += 1 - for (const id of matched) linkedBySubject.set(id, (linkedBySubject.get(id) ?? 0) + 1) + descriptors += result.descriptorCount ?? 0 } else if (result.status === 'failed') { failed += 1 failures.push({ @@ -89,48 +86,23 @@ export const summarizeFaceIndexResults = (results) => { } } - return { - indexed, - failed, - linkedPhotos, - linksBySubject: [...linkedBySubject.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([subjectId, photos]) => ({ subjectId, photos })), - failures, - } + return { indexed, failed, descriptors, failures } } /** * The `--verify` inventory: the honest gap is `stale` — approved photos whose - * marker is not the current index revision (new enrollment, new model or never - * indexed). Consent-edited subjects are reported separately: the query already - * fails closed on them, but the operator must see they need re-consent. + * marker is not the current index revision (never indexed or a model change). + * `descriptors` is the stored face count of the current model. * - * @param {{ queue: { indexKey: string, eligibleSubjects: number, totalApproved: number, stale: number }, subjects: Array<{ id: number, status: string | null, model: string | null, consentHash: string | null, matchedPhotoIds: number[] }>, currentConsentHash: string | null }} input + * @param {{ queue: { indexKey: string, totalApproved: number, stale: number }, descriptors: number }} input */ -export const summarizeFaceIndexInventory = ({ queue, subjects, currentConsentHash }) => { - const active = subjects.filter((subject) => subject.status === 'active') - const staleConsent = active.filter((subject) => subject.consentHash !== currentConsentHash) - const linksBySubject = subjects - .filter((subject) => subject.matchedPhotoIds.length > 0) - .map((subject) => ({ subjectId: subject.id, photos: subject.matchedPhotoIds.length })) - .sort((a, b) => a.subjectId - b.subjectId) - - return { - indexKey: queue.indexKey, - totalApproved: queue.totalApproved, - indexed: Math.max(0, queue.totalApproved - queue.stale), - stale: queue.stale, - // The eligibility predicate belongs to the batch owner; the inventory just - // reports its count (a corrupted vector never counts as eligible). - eligibleSubjects: queue.eligibleSubjects, - activeSubjects: active.length, - removedSubjects: subjects.length - active.length, - staleConsentSubjects: staleConsent.length, - linkedPhotos: linksBySubject.reduce((sum, link) => sum + link.photos, 0), - linksBySubject, - } -} +export const summarizeFaceIndexInventory = ({ queue, descriptors }) => ({ + indexKey: queue.indexKey, + totalApproved: queue.totalApproved, + indexed: Math.max(0, queue.totalApproved - queue.stale), + stale: queue.stale, + descriptors, +}) /** One line per counted state of the inventory (pt-BR, operator-facing). */ export const formatFaceIndexInventory = (inventory, model) => [ @@ -138,28 +110,20 @@ export const formatFaceIndexInventory = (inventory, model) => [ `[faces:index] fotos aprovadas: ${inventory.totalApproved}`, `[faces:index] indexadas na revisão atual: ${inventory.indexed}`, `[faces:index] desatualizadas (a processar): ${inventory.stale}`, - `[faces:index] sujeitos: ${inventory.activeSubjects} ativo(s), ${inventory.removedSubjects} removido(s), ${inventory.eligibleSubjects} elegível(is)`, - `[faces:index] sujeitos com consentimento vencido (inelegíveis até re-consentir): ${inventory.staleConsentSubjects}`, - `[faces:index] vínculos foto↔sujeito: ${inventory.linkedPhotos}`, - ...inventory.linksBySubject.map( - (link) => `[faces:index] sujeito #${link.subjectId}: ${link.photos} foto(s)`, - ), + `[faces:index] rostos no índice (modelo atual): ${inventory.descriptors}`, ] /** The receipt + human lines of one plan/apply run. */ export const formatFaceIndexReport = (report) => { const lines = [ `[faces:index] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`, - `[faces:index] fila: ${report.queue.items} foto(s) a processar (aprovadas: ${report.queue.totalApproved} | sujeitos elegíveis: ${report.queue.eligibleSubjects})`, + `[faces:index] fila: ${report.queue.items} foto(s) a processar (aprovadas: ${report.queue.totalApproved})`, ] if (report.mode !== 'plan') { lines.push( - `[faces:index] processadas: ${report.summary.indexed} | vinculadas a alguém: ${report.summary.linkedPhotos} | falharam: ${report.summary.failed}`, + `[faces:index] processadas: ${report.summary.indexed} | rostos indexados: ${report.summary.descriptors} | falharam: ${report.summary.failed}`, ) - for (const link of report.summary.linksBySubject) { - lines.push(`[faces:index] sujeito #${link.subjectId}: ${link.photos} foto(s)`) - } } for (const failure of report.summary?.failures ?? []) { diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 128163a8..bf11cdb4 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -44,6 +44,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/agent-session.mjs', 'scripts/lib/ansi.mjs', 'scripts/lib/archiveCatalogPlan.mjs', + 'scripts/lib/archivePublishPlan.mjs', 'scripts/lib/archiveVisionApi.mjs', 'scripts/lib/auto-unblock.mjs', 'scripts/lib/briefingContent.mjs', @@ -78,7 +79,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/dossieResearch.mjs', 'scripts/lib/dossieUnit.mjs', 'scripts/lib/e2e-affected-manifest.mjs', - 'scripts/lib/faceEnrollPlan.mjs', + 'scripts/lib/faceConsentTexts.mjs', 'scripts/lib/faceIndexPlan.mjs', 'scripts/lib/flickrApi.mjs', 'scripts/lib/flickrPlan.mjs', diff --git a/scripts/publish-archive-photos.mjs b/scripts/publish-archive-photos.mjs new file mode 100644 index 00000000..21474978 --- /dev/null +++ b/scripts/publish-archive-photos.mjs @@ -0,0 +1,213 @@ +/** + * C242 — publishes the photo archive in bulk: every `draft` photo becomes + * `approved` so the public album (/fotos, C233) and the selfie search (C234/ + * C242) have what to show. The curation act is explicitly human and bulk + * (decision of the owner, 2026-10-01); `removed` is NEVER touched — a takedown + * stays a takedown. + * + * Modes: + * pnpm archive:publish plan/dry-run (default) — the eligible queue; no write + * pnpm archive:publish --apply approves the drafts; requires + * ARCHIVE_PUBLISH_CONFIRM=1 outside local dev + * and the declared TEQO_ENV matching the + * exact database + * + * Options: --limit (canary), --out (receipts, default `data/archive`), + * --help. + * + * Preconditions enforced fail-closed: the album global must have a valid + * removal channel configured (the same guard the per-photo approval uses) — + * a public photo always carries the takedown path. + * + * Runbook (after --apply, bust the public cache from a process that owns it): + * curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \ + * -H "x-revalidate-secret: $REVALIDATE_SECRET" + */ +import { join } from 'node:path' + +import { getPayload } from 'payload' + +import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts' + +import { + ARCHIVE_PUBLISH_DEFAULT_OUT_DIR, + archivePublishReportStamp, + formatArchivePublishReport, + parseArchivePublishCliArgs, + summarizeArchivePublishResults, +} from './lib/archivePublishPlan.mjs' +import { + TEQO_ENV_DATABASE_BY_ENV, + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, + writeRepoFile, +} from './lib/cli.mjs' + +loadCliEnv() + +const die = dieWithLabel('archive:publish') + +const WRITE_CONFIRM_FLAG = 'ARCHIVE_PUBLISH_CONFIRM' +const PAGE_SIZE = 200 + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +const HELP = ` +Uso: pnpm archive:publish [opções] + +Aprova em lote as fotos DRAFT do acervo (publicationStatus: draft → approved), +para o álbum público e a busca por selfie. Fotos "removed" nunca são tocadas. + +Modos: + (sem flag) plano/dry-run — fila elegível; sem escrita + --apply aprova os drafts (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) + +Opções: + --limit processa no máximo n fotos (canário) + --out diretório dos recibos (default ${ARCHIVE_PUBLISH_DEFAULT_OUT_DIR}) + --help esta ajuda + +Ambiente: + DATABASE_URL alvo (obrigatório em todos os modos) + +--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} +casando o banco exato. O plano é read-only. +` + +const writeReport = async (options, report, suffix = '') => { + const stamp = archivePublishReportStamp(report.runAt) + const relativePath = join(options.out, 'reports', `archive-publish-${stamp}${suffix}.json`) + await writeRepoFile({ + label: 'archive:publish', + root: process.cwd(), + relativePath, + body: JSON.stringify(report, null, 2), + }) + return join(process.cwd(), relativePath) +} + +async function main() { + const options = parseArchivePublishCliArgs(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' : 'plan' + const runAt = new Date().toISOString() + console.log(`[archive:publish] alvo: ${databaseTarget()} | modo: ${mode}`) + + if (mode === 'apply') { + assertWriteConfirm({ + label: 'archive:publish', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm archive:publish --apply', + }) + assertEnvironmentDatabaseTarget() + } + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + const startedAt = Date.now() + + const album = await payload + .findGlobal({ slug: 'photoAlbum', depth: 0, overrideAccess: true }) + .catch(() => null) + if (!isArchivePhotoRemovalChannelUrl(album?.removalChannelUrl)) { + die( + 'canal de remoção não configurado no global Álbum de fotos — aprove pelo admin antes do lote (a foto pública sempre carrega o caminho de remoção).', + ) + } + + const drafts = await payload.find({ + collection: 'archivePhoto', + where: { publicationStatus: { equals: 'draft' } }, + sort: ['id'], + depth: 0, + limit: 0, + pagination: false, + select: { id: true }, + // Intentional bypass: the publish CLI is a trusted operator with no session. + overrideAccess: true, + }) + const queue = options.limit ? drafts.docs.slice(0, options.limit) : drafts.docs + + if (mode === 'plan') { + const report = { + runAt, + mode, + target: databaseTarget(), + queue: { totalDrafts: drafts.docs.length, items: queue.length }, + summary: { approved: 0, failed: 0, failures: [] }, + preview: queue.slice(0, 20).map((doc) => ({ photoId: doc.id })), + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatArchivePublishReport(report)) console.log(line) + console.log(`\n[archive:publish] recibo JSON: ${reportPath}`) + process.exit(0) + } + + if (queue.length === 0) { + console.log('[archive:publish] nada a fazer — nenhuma foto draft elegível.') + process.exit(0) + } + + const results = [] + for (let index = 0; index < queue.length; index += 1) { + const photo = queue[index] + try { + await payload.update({ + collection: 'archivePhoto', + id: photo.id, + data: { publicationStatus: 'approved' }, + depth: 0, + // Intentional bypass: same trusted operator. + overrideAccess: true, + }) + results.push({ photoId: photo.id, status: 'approved' }) + } catch (error) { + results.push({ + photoId: photo.id, + status: 'failed', + error: error?.message ?? String(error), + }) + } + + if ((index + 1) % PAGE_SIZE === 0) { + console.log(`[archive:publish] … ${index + 1}/${queue.length}`) + } + } + + const summary = summarizeArchivePublishResults(results) + const report = { + runAt, + mode, + target: databaseTarget(), + queue: { totalDrafts: drafts.docs.length, items: queue.length }, + summary, + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatArchivePublishReport(report)) console.log(line) + console.log( + '\n[archive:publish] lembre de bustar a tag pública (processo Next): curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"', + ) + console.log(`[archive:publish] recibo JSON: ${reportPath}`) + + if (summary.approved === 0 && results.length > 0) { + die('nenhuma foto foi aprovada — falhas listadas no recibo.') + } + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/scripts/seed-face-consents.mjs b/scripts/seed-face-consents.mjs new file mode 100644 index 00000000..61e2bc47 --- /dev/null +++ b/scripts/seed-face-consents.mjs @@ -0,0 +1,159 @@ +/** + * C242 — provisions the two Consent texts of the selfie search by their stable + * keys (`busca-selfie-fotos` query consent, `busca-selfie-indice` public index + * notice). Idempotent: creates the row when missing, updates the text when the + * repo text changed (Payload versions the consent). + * + * Modes: + * pnpm seed:face-consents plan/dry-run (default) — shows the state; no write + * pnpm seed:face-consents --apply upserts the texts; requires + * FACE_CONSENT_SEED_CONFIRM=1 outside local dev + * and the declared TEQO_ENV matching the + * exact database + * + * The texts are versioned in `scripts/lib/faceConsentTexts.mjs` — the delivery + * that opens the surface owns what the visitor reads. + */ +import { getPayload } from 'payload' + +import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts' + +import { + TEQO_ENV_DATABASE_BY_ENV, + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, +} from './lib/cli.mjs' +import { + FACE_INDEX_CONSENT_PARAGRAPHS, + FACE_INDEX_CONSENT_TEXT, + FACE_SEARCH_CONSENT_PARAGRAPHS, + FACE_SEARCH_CONSENT_TEXT, +} from './lib/faceConsentTexts.mjs' + +loadCliEnv() + +const die = dieWithLabel('seed:face-consents') +const WRITE_CONFIRM_FLAG = 'FACE_CONSENT_SEED_CONFIRM' + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +const HELP = ` +Uso: pnpm seed:face-consents [opções] + +Cria/atualiza os dois Consentimentos da busca por selfie: + ${FACE_SEARCH_CONSENT_KEY} consentimento da consulta (selfie + biometria) + ${FACE_INDEX_CONSENT_KEY} aviso público do índice anônimo do acervo + +Modos: + (sem flag) plano/dry-run — estado atual e prévia dos textos; sem escrita + --apply grava os textos (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) + +Ambiente: + DATABASE_URL alvo (obrigatório em todos os modos) + +--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} +casando o banco exato. O plano é read-only. +` + +const upsertConsent = async (payload, key, text) => { + const existing = await payload.find({ + collection: 'consent', + where: { key: { equals: key } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + const found = existing.docs[0] + if (found) { + await payload.update({ + collection: 'consent', + id: found.id, + data: { text }, + depth: 0, + overrideAccess: true, + }) + return { id: found.id, action: 'updated' } + } + + const created = await payload.create({ + collection: 'consent', + data: { key, text }, + depth: 0, + overrideAccess: true, + }) + return { id: created.id, action: 'created' } +} + +async function main() { + const argv = process.argv.slice(2) + if (argv.includes('--help') || argv.includes('-h')) { + console.log(HELP) + process.exit(0) + } + const unknown = argv.find((arg) => arg !== '--apply') + if (unknown) die(`argumento desconhecido: ${unknown} — uso: pnpm seed:face-consents [--apply].`) + + const apply = argv.includes('--apply') + if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') + + console.log(`[seed:face-consents] alvo: ${databaseTarget()} | modo: ${apply ? 'apply' : 'plan'}`) + + if (apply) { + assertWriteConfirm({ + label: 'seed:face-consents', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm seed:face-consents --apply', + }) + assertEnvironmentDatabaseTarget() + } + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + + const entries = [ + { + key: FACE_SEARCH_CONSENT_KEY, + text: FACE_SEARCH_CONSENT_TEXT, + paragraphs: FACE_SEARCH_CONSENT_PARAGRAPHS, + }, + { + key: FACE_INDEX_CONSENT_KEY, + text: FACE_INDEX_CONSENT_TEXT, + paragraphs: FACE_INDEX_CONSENT_PARAGRAPHS, + }, + ] + + for (const entry of entries) { + const existing = await payload.find({ + collection: 'consent', + where: { key: { equals: entry.key } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + const state = existing.docs[0] ? `existe (#${existing.docs[0].id})` : 'ausente' + console.log( + `[seed:face-consents] ${entry.key}: ${state} | texto com ${entry.paragraphs.length} parágrafo(s)`, + ) + + if (!apply) continue + const result = await upsertConsent(payload, entry.key, entry.text) + console.log(`[seed:face-consents] ${entry.key}: ${result.action} (#${result.id})`) + } + + if (!apply) { + console.log('\n[seed:face-consents] dry-run — use --apply para gravar.') + process.exit(0) + } + console.log('\n[seed:face-consents] OK — Consentimentos da busca por selfie provisionados.') + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index a6badf12..20faa7d5 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -6,10 +6,12 @@ import { CampaignHero } from '@/components/CampaignHero' import { CampaignProblemCard } from '@/components/CampaignProblemCard' import { CampaignStorySection } from '@/components/CampaignStorySection' import { ContentPieceHomeSection } from '@/components/conteudos/ContentPieceHomeSection' +import { SelfieSearchHomeSection } from '@/components/fotos/SelfieSearchHomeSection' import { JingleHomeSection } from '@/components/jingles/JingleHomeSection' import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads' import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking' import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads' +import { getCachedGlobal } from '@/utilities/globalReads' import { getPublishedJingleItems } from '@/utilities/jingleReads' import type { ReactNode } from 'react' import { CampaignCardsSection } from './CampaignCardsSection' @@ -123,13 +125,18 @@ export default async function HomePage() { // S27/S39 — one cached listing (tag `contentPieces`) feeds both the discovery // flag of the footer and the home sample; zero pieces hides both (fail-closed). // C233 — the same contract for the "Fotos" discovery flag (tag `archivePhotos`). - const [pixelId, jingles, contentPieces, showFotos] = await Promise.all([ + const [pixelId, jingles, contentPieces, showFotos, album] = await Promise.all([ getCampaignHomeMetaPixelId(), getPublishedJingleItems(), getPublishedContentPieceItems(), hasPublishedArchivePhotos(), + getCachedGlobal('photoAlbum')(), ]) const homeJingles = jingles.slice(0, 3) + // C243 — the discovery section only exists while the selfie search is open + // (album published + own flag + approved photos); closed leaves no dead CTA. + const showSelfieSearch = + album.published !== false && album.selfieSearchEnabled === true && showFotos return ( <> @@ -255,6 +262,7 @@ export default async function HomePage() { homeJingles.length} /> {contentPieces.length > 0 ? : null} + {showSelfieSearch ? : null} diff --git a/src/app/(frontend)/api/fotos/selfie/route.ts b/src/app/(frontend)/api/fotos/selfie/route.ts index c24be0bb..e16cea20 100644 --- a/src/app/(frontend)/api/fotos/selfie/route.ts +++ b/src/app/(frontend)/api/fotos/selfie/route.ts @@ -3,12 +3,7 @@ import { NextResponse } from 'next/server' import { getPayload } from 'payload' import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys' -import { - FACE_SEARCH_MODEL, - FACE_SEARCH_RESULT_LIMIT, - findFaceMatch, - toFaceSearchPhotoView, -} from '@/lib/faceSearch' +import { FACE_SEARCH_RESULT_LIMIT, toFaceSearchPhotoView } from '@/lib/faceSearch' import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch' import { getApprovedArchivePhotoItems } from '@/utilities/archivePhotos/archivePhotoReads' import { readBoundedRequestBody } from '@/utilities/boundedRequestBody' @@ -17,28 +12,31 @@ import { checkContentEventRateLimit, contentEventClientKey, } from '@/utilities/content/contentEventRateLimit' -import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment' import { - getFaceSubjectMatchedPhotoIds, - listSearchableFaceSubjects, -} from '@/utilities/faceSubjects/faceSubjectReads' + deleteFaceDescriptorMatches, + findMatchedFacePhotoIds, + loadFaceDescriptorIndex, +} from '@/utilities/faceIndex/faceDescriptorReads' import { isSameOriginRequest } from '@/utilities/sameOriginRequest' export const dynamic = 'force-dynamic' /** - * C234 — the anonymous endpoint of the selfie search. The browser computes the - * face descriptor ON DEVICE (the selfie image never reaches this server) and - * sends only the 128-float vector plus the intent; the answer is a list of - * approved photos, never a score and never a third-party name. + * C242 — the anonymous endpoint of the selfie search (scope B: the anonymous + * descriptor index of the approved archive, decision of 2026-10-01). The + * browser computes the face descriptor ON DEVICE (the selfie image never + * reaches this server) and sends only the 128-float vector plus the intent; the + * answer is a list of approved photos, never a score and never a third-party + * name. * * Fail-closed in layers: same-origin, bounded body, strict schema, a budget * much stricter than the beacon's (the endpoint answers a membership oracle), - * the album's `selfieSearchEnabled` kill switch AND the query Consent resolved - * server-side — the page closing is convenience, this is the gate. Scope A/C - * of the gate (PR #1370): only subjects enrolled with consent answer; the - * anonymous archive index (B) does not exist. `leave-index` is authorized by - * the matched descriptor itself. + * the album's `selfieSearchEnabled` kill switch AND both configured Consents + * resolved server-side — the query consent (`busca-selfie-fotos`, accepted by + * the visitor) and the public index notice (`busca-selfie-indice`, the + * transparency document of the anonymous index). `leave-index` is authorized + * by the matched descriptor itself and erases the person's faces from the + * index. */ const NO_STORE = { 'Cache-Control': 'no-store' } as const @@ -79,45 +77,33 @@ export const POST = async (request: Request): Promise => { } const payload = await getPayload({ config }) - // Direct read (no cache): the kill switch and the consent must be the state + // Direct read (no cache): the kill switch and the consents must be the state // of this moment — the endpoint is the gate, the page is convenience. The // album global's read access is public by contract, so no bypass is needed. - const [album, searchConsent] = await Promise.all([ + const [album, searchConsent, indexNotice] = await Promise.all([ payload.findGlobal({ slug: 'photoAlbum', depth: 0 }).catch(() => null), getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY), + getConsentByKey(payload, FACE_INDEX_CONSENT_KEY), ]) if (album?.published === false || album?.selfieSearchEnabled !== true) { return jsonResponse({ ok: false, error: 'closed' }, 404) } - if (!searchConsent) return jsonResponse({ ok: false, error: 'consent' }, 503) - - const [subjects, indexConsent] = await Promise.all([ - listSearchableFaceSubjects(payload), - getConsentByKey(payload, FACE_INDEX_CONSENT_KEY), - ]) - - const match = indexConsent - ? findFaceMatch({ - vector: parsed.data.vector, - subjects, - model: FACE_SEARCH_MODEL, - consentHash: indexConsent.contentHash, - }) - : null + if (!searchConsent || !indexNotice) return jsonResponse({ ok: false, error: 'consent' }, 503) if (parsed.data.intent === 'leave-index') { - if (!match) return jsonResponse({ ok: true, removed: false }) - await removeFaceSubjectFromIndex({ payload, id: match.id }) - return jsonResponse({ ok: true, removed: true }) + const removedFaces = await deleteFaceDescriptorMatches({ + payload, + vector: parsed.data.vector, + }) + return jsonResponse({ ok: true, removed: removedFaces > 0 }) } - if (!match) return jsonResponse({ ok: true, found: false }) + const index = await loadFaceDescriptorIndex(payload) + const matchedIds = findMatchedFacePhotoIds({ vector: parsed.data.vector, entries: index }) + if (matchedIds.length === 0) return jsonResponse({ ok: true, found: false }) - const [matchedIds, approved] = await Promise.all([ - getFaceSubjectMatchedPhotoIds(payload, match.id), - getApprovedArchivePhotoItems(), - ]) + const approved = await getApprovedArchivePhotoItems() const linked = new Set(matchedIds) const photos = approved .filter((item) => linked.has(item.id)) diff --git a/src/app/(frontend)/fotos/encontre/page.tsx b/src/app/(frontend)/fotos/encontre/page.tsx index 2fc240de..8414873c 100644 --- a/src/app/(frontend)/fotos/encontre/page.tsx +++ b/src/app/(frontend)/fotos/encontre/page.tsx @@ -14,7 +14,7 @@ import { ARCHIVE_PHOTO_ALBUM_ENTRY_PATH, ARCHIVE_PHOTO_ALBUM_PATH, } from '@/lib/archivePhotoPublicCatalog' -import { FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys' +import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys' import { getConsentByKey } from '@/utilities/campaignConsent' import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads' import { getCachedGlobal } from '@/utilities/globalReads' @@ -79,7 +79,10 @@ export default async function FotosEncontrePage() { if (album.published === false || album.selfieSearchEnabled !== true) notFound() const payload = await getPayload({ config }) - const consent = await getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY) + const [consent, indexNotice] = await Promise.all([ + getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY), + getConsentByKey(payload, FACE_INDEX_CONSENT_KEY), + ]) const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl) ? album.removalChannelUrl.trim() : null @@ -98,9 +101,10 @@ export default async function FotosEncontrePage() { - {consent ? ( + {consent && indexNotice ? ( } + noticeText={} removalChannelUrl={removalChannelUrl} /> ) : ( diff --git a/src/collections/ArchivePhoto.ts b/src/collections/ArchivePhoto.ts index 10a6a465..57fa135b 100644 --- a/src/collections/ArchivePhoto.ts +++ b/src/collections/ArchivePhoto.ts @@ -27,6 +27,7 @@ import { import { SPEECH_TOPICS } from '@/lib/speechFacets' import { canReadArchivePhoto, payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateArchivePhotosListing } from '@/utilities/documents' +import { purgeFaceDescriptorsForPhoto } from '@/utilities/faceIndex/faceDescriptorIndex' /** * C231 — private upload collection of the Flickr photo archive: the ORIGINAL @@ -282,6 +283,24 @@ const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({ return doc } +/** + * C242 — a photo that stops being `approved` cannot keep answering the selfie + * search: the anonymous descriptor rows die with the public status (and on + * deletion), the same fail-closed spirit of the C233 read. The FK cascade + * covers direct SQL deletes; this covers the Local API lifecycle. + */ +const purgeFaceDescriptorsAfterChange: CollectionAfterChangeHook = async ({ doc, req }) => { + if (doc.publicationStatus !== 'approved') { + await purgeFaceDescriptorsForPhoto({ payload: req.payload, photoId: doc.id, req }) + } + return doc +} + +const purgeFaceDescriptorsAfterDelete: CollectionAfterDeleteHook = async ({ doc, req }) => { + await purgeFaceDescriptorsForPhoto({ payload: req.payload, photoId: doc.id, req }) + return doc +} + /** * C234 — the batch state of the selfie-search index (`pnpm faces:index`). The * `checkedKey` is what lets the batch skip a photo honestly: it summarizes the @@ -346,8 +365,8 @@ export const ArchivePhoto: CollectionConfig = { }, hooks: { beforeValidate: [deriveArchivePhotoCatalogIndex, requireRemovalChannelForApproval], - afterChange: [revalidateArchivePhotosListingAfterChange], - afterDelete: [revalidateArchivePhotosListingAfterDelete], + afterChange: [revalidateArchivePhotosListingAfterChange, purgeFaceDescriptorsAfterChange], + afterDelete: [revalidateArchivePhotosListingAfterDelete, purgeFaceDescriptorsAfterDelete], }, fields: [ { diff --git a/src/collections/ArchivePhotoFace.ts b/src/collections/ArchivePhotoFace.ts new file mode 100644 index 00000000..061fb9b1 --- /dev/null +++ b/src/collections/ArchivePhotoFace.ts @@ -0,0 +1,79 @@ +import type { CollectionConfig } from 'payload' + +import { payloadAdminOnly } from '@/utilities/campaignAccess' + +/** + * C242 — one detected face of an APPROVED archive photo: the anonymous index + * that lets any visitor find themselves (scope B of the gate, decision of + * 2026-10-01; supersedes the A/C `faceSubject` enrollment). The row carries no + * identity, no link to `Contact`/leadership and no name — only the photo it was + * found in, the 128-float descriptor and the model that produced it. + * + * Machine-only data: no admin surface (`admin.hidden`), admin-only access, and + * the descriptor never leaves the server. Written exclusively by the + * `pnpm faces:index` batch (trusted operator, `overrideAccess`), deleted by the + * opt-out (`leave-index` matches the person's own descriptor) and by the photo + * lifecycle hooks on unapproval/deletion. A photo that is not `approved` must + * never have rows here. + */ +export const ArchivePhotoFace: CollectionConfig = { + slug: 'archivePhotoFace', + labels: { + singular: 'Rosto no índice', + plural: 'Rostos no índice', + }, + admin: { + group: 'Comunicação', + hidden: true, + description: + 'Índice facial anônimo do acervo público aprovado (C242). Operado pela CLI faces:index; o descriptor nunca é exposto.', + }, + access: { + create: payloadAdminOnly, + read: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, + }, + fields: [ + { + name: 'photo', + type: 'relationship', + relationTo: 'archivePhoto', + label: 'Foto', + required: true, + index: true, + admin: { + description: 'Foto aprovada em que o rosto foi detectado.', + }, + }, + { + name: 'model', + type: 'text', + label: 'Modelo', + required: true, + index: true, + admin: { + readOnly: true, + description: + 'Modelo do descriptor (troca de modelo invalida a revisão e o lote reprocessa).', + }, + }, + { + name: 'detectedAt', + type: 'date', + label: 'Detectado em', + admin: { + readOnly: true, + }, + }, + { + name: 'vector', + type: 'json', + label: 'Descriptor', + admin: { + hidden: true, + readOnly: true, + }, + }, + ], +} diff --git a/src/collections/Consent.ts b/src/collections/Consent.ts index ceef3787..24f44348 100644 --- a/src/collections/Consent.ts +++ b/src/collections/Consent.ts @@ -22,7 +22,6 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ leaderships, supporters, voteSupporters, - faceSubjects, ] = await Promise.all([ req.payload.count({ collection: 'subscription', @@ -66,14 +65,6 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ overrideAccess: true, req, }), - // C234 — face-subject references: intentional access bypass like every - // count above (the hook must see every reference to decide). - req.payload.count({ - collection: 'faceSubject', - where: { consent: { equals: id } }, - overrideAccess: true, - req, - }), ]) const references: Array<[number, string]> = [ @@ -84,7 +75,6 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ [leaderships.totalDocs, 'lideranças'], [supporters.totalDocs, 'apoiadores'], [voteSupporters.totalDocs, 'apoiadores (intenção de voto)'], - [faceSubjects.totalDocs, 'pessoas no índice de busca por selfie'], ] const used = references.filter(([count]) => count > 0) if (used.length === 0) return diff --git a/src/collections/FaceSubject.ts b/src/collections/FaceSubject.ts deleted file mode 100644 index 802d600a..00000000 --- a/src/collections/FaceSubject.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { CollectionConfig } from 'payload' - -import { payloadAdminOnly } from '@/utilities/campaignAccess' - -/** - * C234 — one person who ADHERED to the selfie search index (A/C scope of the - * gate, PR #1370): the enrollment is an ops act of the assessoria with the - * person (signed consent, `pnpm faces:enroll`), never self-service and never - * inferred from the archive. - * - * This is not a people registry: it has no link to `Contact`/leadership, no - * public read and no surface of its own — it exists to (a) hold the consented - * descriptor that lets the person find themselves and (b) carry the audit - * trail of the adherence (`consent` + `consentHash` + `model` + status). The - * descriptor is written only by the CLI and never leaves the server; the - * matched photos are a derived link the batch replaces on every run. A - * `removed` subject keeps its row (the attendance record) but not the vector. - */ -export const FaceSubject: CollectionConfig = { - slug: 'faceSubject', - labels: { - singular: 'Pessoa no índice facial', - plural: 'Pessoas no índice facial', - }, - admin: { - group: 'Comunicação', - description: - 'Pessoas que consentiram entrar no índice da busca por selfie (/fotos/encontre). Operado pela CLI faces:enroll/faces:index; a remoção é registrada no status.', - useAsTitle: 'label', - defaultColumns: ['label', 'status', 'model', 'removedAt'], - }, - access: { - create: payloadAdminOnly, - read: payloadAdminOnly, - update: payloadAdminOnly, - delete: payloadAdminOnly, - }, - fields: [ - { - name: 'label', - type: 'text', - label: 'Nome (interno)', - required: true, - admin: { - description: 'Como a assessoria identifica a pessoa; nunca aparece no site.', - }, - }, - { - name: 'consent', - type: 'relationship', - relationTo: 'consent', - label: 'Consentimento (adesão)', - required: true, - index: true, - admin: { - description: 'O Consent da adesão ao índice (chave estável busca-selfie-indice).', - }, - }, - { - name: 'consentHash', - type: 'text', - label: 'Hash do consentimento', - admin: { - readOnly: true, - description: - 'Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.', - }, - }, - { - name: 'model', - type: 'text', - label: 'Modelo', - admin: { - readOnly: true, - description: 'Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).', - }, - }, - { - name: 'enrolledAt', - type: 'date', - label: 'Inscrita em', - admin: { - readOnly: true, - description: - 'Muda a cada enrollment/re-enrollment; é o que marca o lote como stale (o updated_at muda também quando o lote escreve os vínculos).', - }, - }, - { - name: 'vector', - type: 'json', - label: 'Descriptor', - admin: { - hidden: true, - readOnly: true, - }, - }, - { - name: 'status', - type: 'select', - label: 'Status', - required: true, - index: true, - defaultValue: 'active', - options: [ - { label: 'Ativa', value: 'active' }, - { label: 'Removida do índice', value: 'removed' }, - ], - admin: { - description: - 'Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.', - }, - }, - { - name: 'removedAt', - type: 'date', - label: 'Removida em', - admin: { - readOnly: true, - description: 'Data do atendimento da saída do índice.', - }, - }, - { - name: 'matchedPhotos', - type: 'relationship', - relationTo: 'archivePhoto', - hasMany: true, - label: 'Fotos vinculadas', - admin: { - readOnly: true, - description: - 'Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.', - }, - }, - ], -} diff --git a/src/components/fotos/SelfieSearchEntry.tsx b/src/components/fotos/SelfieSearchEntry.tsx index 845877e8..28b6d450 100644 --- a/src/components/fotos/SelfieSearchEntry.tsx +++ b/src/components/fotos/SelfieSearchEntry.tsx @@ -24,11 +24,12 @@ export const SelfieSearchEntry = () => (

- Sua selfie fica neste aparelho. A busca é para quem aderiu ao índice. + Sua selfie fica neste aparelho. A busca cobre o acervo público aprovado, e você pode + sair do índice. - Use uma selfie sua. A imagem não sai deste dispositivo e a busca cobre quem autorizou - participar do índice. + Use uma selfie sua. A imagem não sai deste dispositivo. A busca cobre o acervo público + aprovado, e você pode retirar seu rosto do índice.

diff --git a/src/components/fotos/SelfieSearchFlow.tsx b/src/components/fotos/SelfieSearchFlow.tsx index 127e6b70..6fbedb09 100644 --- a/src/components/fotos/SelfieSearchFlow.tsx +++ b/src/components/fotos/SelfieSearchFlow.tsx @@ -72,9 +72,11 @@ const STEP_SHELL = 'mx-auto w-full max-w-[650px]' */ export const SelfieSearchFlow = ({ consentText, + noticeText, removalChannelUrl, }: { consentText: ReactNode + noticeText: ReactNode removalChannelUrl: string | null }) => { const [step, setStep] = useState({ kind: 'consent' }) @@ -234,8 +236,34 @@ export const SelfieSearchFlow = ({ > Sua face é um dado biométrico sensível -
- {consentText} +
{consentText}
+
+
+
@@ -251,10 +279,11 @@ export const SelfieSearchFlow = ({
@@ -267,8 +296,8 @@ export const SelfieSearchFlow = ({ className="mt-0.5 size-5 shrink-0 accent-[#184e92]" /> - Li e autorizo o uso temporário da minha biometria facial para procurar a mim mesmo - neste álbum. + Li o aviso e autorizo o uso da minha selfie e da minha biometria somente nesta + consulta para procurar a mim mesmo no álbum.

@@ -456,7 +485,7 @@ export const SelfieSearchFlow = ({