mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C242/C243): abre a busca por selfie a qualquer visitante (escopo B) + seção na homepage
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.
This commit is contained in:
parent
77e7b0efde
commit
cd4942d9e2
55 changed files with 23591 additions and 1897 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-10-01-c242-c243.md
Normal file
1
docs/changelog/2026-10-01-c242-c243.md
Normal file
|
|
@ -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 `<details>`. 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`.
|
||||
|
|
@ -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 "<nome interno>" --selfie /app/data/face-selfies/<arquivo>.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 "<nome interno>" --selfie /app/data/face-selfies/<arquivo>.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 <id>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>Design — C234 · Busca de fotos por selfie</title>
|
||||
<title>Design — C234/C242 · Busca de fotos por selfie</title>
|
||||
<style type="text/tailwindcss">
|
||||
@theme inline {
|
||||
--font-sans: Inter, Arimo, ui-sans-serif, system-ui, sans-serif;
|
||||
|
|
@ -225,28 +225,29 @@
|
|||
<header class="max-w-3xl">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="rounded-full bg-[#e4102f] px-3 py-1 text-xs font-extrabold text-white"
|
||||
>C234 · gate hi-fi</span
|
||||
>C234 + C242 · gate hi-fi</span
|
||||
><span class="rounded-full border border-border bg-white px-3 py-1 text-xs font-bold"
|
||||
>Fluxo sensível · LGPD fail-closed</span
|
||||
><span
|
||||
class="rounded-full border border-[#184e92]/20 bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
|
||||
>Estendido para a execução · 27 set 2026</span
|
||||
>Escopo B · 01 out 2026</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="display mt-4 text-3xl font-black tracking-[-.03em]">
|
||||
Encontre você nas fotos — privado, explícito e sem placar de semelhança
|
||||
</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
A pessoa parte do álbum, entende o uso de biometria, escolhe a própria selfie e recebe
|
||||
apenas fotos públicas aprovadas em que aparece. A selfie permanece no dispositivo.
|
||||
Qualquer visitante pode partir do álbum, entender o uso de biometria, escolher a própria
|
||||
selfie e receber apenas fotos públicas aprovadas em que aparece. A selfie permanece no
|
||||
dispositivo e é possível sair do índice.
|
||||
</p>
|
||||
<div class="mt-5 rounded-xl border border-[#184e92]/15 bg-[#f9faff] p-4 text-sm leading-6">
|
||||
<p class="font-extrabold text-[#184e92]">Deltas de execução desta revisão</p>
|
||||
<p class="font-extrabold text-[#184e92]">Delta de produto desta revisão · C242</p>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
A entrada agora respeita o hero e a grade reais do C233; o fluxo é uma página própria em
|
||||
<b class="text-foreground">/fotos/encontre</b>; resultado, vazio A/C, falhas do
|
||||
processamento e opt-out sem reconhecimento ganham estados explícitos. Cenas revisadas
|
||||
mantêm o número aprovado e recebem selo azul; cenas novas começam em 09.
|
||||
O índice deixa de depender de adesão prévia por pessoa: ele cobre os rostos do acervo
|
||||
público aprovado, sem nomes ou vínculo com cadastro. O aviso público do índice aparece
|
||||
antes da autorização de consulta; “Minha presença” permite retirar o próprio rosto a
|
||||
qualquer momento. As cenas sem delta visual permanecem intactas.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -259,7 +260,7 @@
|
|||
<section aria-labelledby="s1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 01</p>
|
||||
<span class="revision-badge">Revisada · C234 execução</span>
|
||||
<span class="revision-badge">Revisada · C242 escopo B</span>
|
||||
</div>
|
||||
<h2 id="s1" class="display mt-1 text-xl font-black">
|
||||
Entrada real na /fotos · flag ligada
|
||||
|
|
@ -317,8 +318,8 @@
|
|||
<div>
|
||||
<h3 class="display text-xl font-black">Encontre você nas fotos</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -393,7 +394,8 @@
|
|||
<div>
|
||||
<h3 class="display text-lg font-black">Encontre você nas fotos</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -447,7 +449,7 @@
|
|||
<section aria-labelledby="s2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 02</p>
|
||||
<span class="revision-badge">Revisada · página /fotos/encontre</span>
|
||||
<span class="revision-badge">Revisada · C242 aviso + consulta</span>
|
||||
</div>
|
||||
<h2 id="s2" class="display mt-1 text-xl font-black">
|
||||
Desktop ~1280 · aviso e consentimento na página própria, não em modal
|
||||
|
|
@ -491,10 +493,30 @@
|
|||
Sua face é um dado biométrico sensível
|
||||
</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
Com sua autorização, comparamos a sua selfie com o índice formado por pessoas que
|
||||
autorizaram participar, para mostrar somente fotos públicas aprovadas em que você
|
||||
aparece.
|
||||
Com sua autorização, usamos a sua selfie somente nesta consulta para procurar você
|
||||
no índice biométrico anônimo do acervo público aprovado.
|
||||
</p>
|
||||
<div class="mt-5 rounded-xl border border-[#184e92]/20 bg-[#eef4fb] p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#shield" /></svg>
|
||||
<div>
|
||||
<p class="text-sm font-extrabold text-[#143c70]">
|
||||
Aviso público sobre o índice de fotos
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#365472]">
|
||||
Rostos presentes nas fotos públicas aprovadas compõem um índice sem nomes e
|
||||
sem vínculo com cadastros de pessoas. Ele serve apenas para localizar as
|
||||
próprias fotos. Você pode retirar seu rosto do índice a qualquer momento em
|
||||
“Minha presença”.
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
class="focus-ring mt-2 inline-flex min-h-11 items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4"
|
||||
>Ler o aviso completo do índice</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
|
||||
<div class="flex gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#device" /></svg>
|
||||
|
|
@ -508,10 +530,9 @@
|
|||
<div class="flex gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#lock" /></svg>
|
||||
<p>
|
||||
<b>Você pode desistir e pedir remoção.</b><br /><span
|
||||
class="text-muted-foreground"
|
||||
>O resultado não mostra nomes de terceiros nem percentual de
|
||||
semelhança.</span
|
||||
<b>Você mantém o controle.</b><br /><span class="text-muted-foreground"
|
||||
>Saia do índice quando quiser. O resultado não mostra nomes de terceiros nem
|
||||
percentual de semelhança.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -522,8 +543,8 @@
|
|||
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded border-2 border-[#184e92] bg-[#184e92] text-white"
|
||||
><svg class="icon size-3.5"><use href="#check" /></svg></span
|
||||
><span
|
||||
>Li e autorizo o uso temporário da minha biometria facial para procurar a mim
|
||||
mesmo neste álbum.</span
|
||||
>Li o aviso e autorizo o uso da minha selfie e da minha biometria somente nesta
|
||||
consulta para procurar a mim mesmo no álbum.</span
|
||||
></label
|
||||
>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
|
|
@ -542,6 +563,15 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 max-w-3xl rounded-xl border border-[#184e92]/15 bg-[#f9faff] p-4 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
<b class="text-foreground">Contrato de conteúdo:</b> o bloco azul é o aviso público
|
||||
<code class="font-bold text-[#184e92]">busca-selfie-indice</code>, não um consentimento de
|
||||
adesão ao índice. A caixa abaixo autoriza apenas o uso da própria selfie e biometria na
|
||||
consulta atual (<code class="font-bold text-[#184e92]">busca-selfie-fotos</code>). Sem
|
||||
qualquer um dos dois textos, vale a Cena 07.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 03 -->
|
||||
|
|
@ -799,7 +829,7 @@
|
|||
<section aria-labelledby="s6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 06</p>
|
||||
<span class="revision-badge">Revisada · índice A/C</span>
|
||||
<span class="revision-badge">Revisada · escopo B</span>
|
||||
</div>
|
||||
<h2 id="s6" class="display mt-1 text-xl font-black">
|
||||
Mobile ~390 · vazio honesto, sem “parecidos”
|
||||
|
|
@ -818,8 +848,8 @@
|
|||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não encontramos fotos suas</h3>
|
||||
<p class="mt-2 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
Esta busca funciona para quem autorizou participar do índice. Também pode não haver
|
||||
uma foto sua entre os registros públicos aprovados.
|
||||
Pode não haver uma foto sua entre os registros públicos aprovados, ou seu rosto pode
|
||||
já ter sido retirado do índice.
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-bold">
|
||||
Não mostramos pessoas parecidas nem tentamos adivinhar quem você é.
|
||||
|
|
@ -836,7 +866,7 @@
|
|||
<section aria-labelledby="s7">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 07</p>
|
||||
<h2 id="s7" class="display mt-1 text-xl font-black">
|
||||
Fail-closed · consentimento não configurado, fluxo indisponível
|
||||
Fail-closed · aviso ou consentimento não configurado, fluxo indisponível
|
||||
</h2>
|
||||
<div class="stage mt-4 max-w-[920px] rounded-2xl border border-border bg-white p-8 sm:p-12">
|
||||
<div class="rounded-2xl border border-amber-300 bg-amber-50 px-6 py-10 text-center">
|
||||
|
|
@ -852,8 +882,8 @@
|
|||
A busca por selfie está indisponível
|
||||
</h3>
|
||||
<p class="mx-auto mt-3 max-w-lg text-sm leading-6 text-amber-900">
|
||||
O texto de consentimento necessário ainda não está configurado. Por segurança, não
|
||||
podemos receber nem processar sua selfie agora.
|
||||
O aviso público do índice ou o consentimento da consulta não está configurado. Por
|
||||
segurança, não podemos receber nem processar sua selfie agora.
|
||||
</p>
|
||||
<p class="mx-auto mt-3 max-w-lg text-sm font-bold text-amber-950">
|
||||
Nenhuma imagem foi capturada ou enviada.
|
||||
|
|
@ -869,10 +899,10 @@
|
|||
<section aria-labelledby="s8">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 08</p>
|
||||
<span class="revision-badge">Revisada · opt-out self-service</span>
|
||||
<span class="revision-badge">Revisada · C242 saída do índice</span>
|
||||
</div>
|
||||
<h2 id="s8" class="display mt-1 text-xl font-black">
|
||||
Mobile ~390 · opt-out e pedido de remoção com confirmação
|
||||
Mobile ~390 · saída do índice a qualquer momento, com confirmação e sucesso
|
||||
</h2>
|
||||
<div class="mt-4 flex flex-wrap items-start gap-8">
|
||||
<div class="stage w-[390px] overflow-hidden rounded-[30px] border border-border bg-white">
|
||||
|
|
@ -885,10 +915,11 @@
|
|||
<p class="text-xs font-black tracking-[.08em] text-[#184e92] uppercase">
|
||||
Privacidade
|
||||
</p>
|
||||
<h3 class="display mt-1 text-2xl font-black">Você decide se quer aparecer</h3>
|
||||
<h3 class="display mt-1 text-2xl font-black">Você controla sua presença no índice</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Para sair do índice, confirme sua identidade com uma selfie. Para retirar uma foto
|
||||
pública, use o canal da equipe responsável pelo acervo.
|
||||
Seu rosto pode fazer parte do índice anônimo por aparecer no acervo aprovado. Para
|
||||
retirá-lo, use uma selfie sua. Para retirar uma foto pública, fale com a equipe do
|
||||
acervo.
|
||||
</p>
|
||||
<label class="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4"
|
||||
><span
|
||||
|
|
@ -897,7 +928,8 @@
|
|||
><span
|
||||
><b class="text-sm">Sair do índice de busca</b
|
||||
><small class="mt-1 block text-xs leading-5 text-muted-foreground"
|
||||
>Seu rosto deixa de ser usado para localizar fotos por selfie.</small
|
||||
>Apaga do índice as referências biométricas que correspondem ao seu
|
||||
rosto.</small
|
||||
></span
|
||||
></label
|
||||
>
|
||||
|
|
@ -931,8 +963,8 @@
|
|||
</div>
|
||||
<h3 class="display mt-4 text-xl font-black">Confirmar saída do índice?</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua
|
||||
presença do índice de busca.
|
||||
Vamos processar sua selfie neste aparelho e, se houver correspondência, apagar do
|
||||
índice as referências biométricas do seu rosto. A selfie não será enviada.
|
||||
</p>
|
||||
<button
|
||||
class="mt-6 flex min-h-11 w-full items-center justify-center rounded-[10px] bg-[#b42318] px-4 text-sm font-extrabold text-white"
|
||||
|
|
@ -944,6 +976,34 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage w-[390px] overflow-hidden rounded-[30px] border border-border bg-white">
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<span class="w-[18px]"></span><span class="text-sm font-bold">Minha presença</span
|
||||
><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<div class="flex min-h-[635px] flex-col items-center justify-center px-6 text-center">
|
||||
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
|
||||
<svg class="icon size-7"><use href="#shield" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Pronto — seu rosto saiu do índice</h3>
|
||||
<p class="mt-3 max-w-[315px] text-sm leading-6 text-muted-foreground">
|
||||
Apagamos as referências biométricas que correspondiam ao seu rosto. Ele deixa de ser
|
||||
encontrado pela busca por selfie.
|
||||
</p>
|
||||
<p class="mt-3 max-w-[315px] text-sm font-bold">
|
||||
As fotos públicas continuam no álbum. Para retirar uma foto específica, use o canal
|
||||
da equipe responsável pelo acervo.
|
||||
</p>
|
||||
<a href="#" class="primary mt-7 w-full">Voltar ao álbum</a>
|
||||
<a
|
||||
href="#"
|
||||
data-source="photoAlbum.removalChannelUrl"
|
||||
class="focus-ring mt-4 inline-flex min-h-11 items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4"
|
||||
>Pedir remoção de uma foto</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -1140,8 +1200,8 @@
|
|||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não encontramos sua presença no índice</h3>
|
||||
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma
|
||||
alteração foi feita.
|
||||
Não encontramos no índice referências biométricas que correspondam ao seu rosto com
|
||||
esta selfie. Nenhuma alteração foi feita.
|
||||
</p>
|
||||
<button class="primary mt-7 w-full">Tentar outra selfie</button>
|
||||
<div class="mt-6 w-full border-t border-border pt-5">
|
||||
|
|
|
|||
126
docs/plans/busca-selfie-escopo-b-impl.md
Normal file
126
docs/plans/busca-selfie-escopo-b-impl.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Impl: C242 — Busca por selfie aberta a qualquer visitante (escopo B)
|
||||
|
||||
Status: aprovado (decisão do dono na sessão, 2026-10-01) — hard-stops humanos registrados (migração de schema, contrato público, Consent/LGPD)
|
||||
Atualizado em: 2026-10-01
|
||||
Issue: — (worktree `work/43`, sem claim)
|
||||
Intenção: docs/plans/busca-selfie-escopo-b.md
|
||||
Design UI (gate): docs/plans/busca-fotos-por-selfie-ui-design.html (estendido no C243; aqui só copy/estados)
|
||||
Appetite restante: herdado (~2–3 dias eng + ops de publicação/indexação) — índice biométrico **anônimo** do acervo aprovado, sem enrollment e sem cadastro de pessoa.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** qualquer visitante com rosto em foto aprovada se encontra; sem aviso/consentimento/flag, nada abre; sem score/nomes; opt-out e remoção funcionam; foto desaprovada sai na hora.
|
||||
- **O que NÃO negociar:**
|
||||
- Fail-closed de Consent/LGPD: sem as duas linhas de Consent com as chaves exatas, o endpoint recusa (503) e a página mostra o estado fechado.
|
||||
- A selfie nunca sai do dispositivo (vetor-only), sem score, sem nome de terceiro, sem retenção.
|
||||
- Resultado = interseção com `getApprovedArchivePhotoItems()` (draft/removida nunca).
|
||||
- Kill switch `photoAlbum.selfieSearchEnabled` e `photoAlbum.published` valem no servidor.
|
||||
- Sem vínculo com `Contact`/leadership; o índice não vira cadastro de pessoas.
|
||||
- **O que reavaliar (achados do reconhecimento):**
|
||||
- O índice A/C persiste **só os vínculos** `matchedPhotos` por subject; os descritores do acervo são transitórios (`faceSubjectPhotoIndex.ts:28-44`). B exige persistir descritores por foto — collection nova.
|
||||
- `findFaceMatch` casa a consulta contra **subjects elegíveis** (`faceSearch.ts:101-128`); B casa a consulta contra **descritores do acervo**.
|
||||
- O marker `faces.checkedKey` hoje resume modelo + revisões de subjects (`faceSubjectPhotoIndex.ts:111-120`); em B resume só o modelo.
|
||||
- `deriveArchivePhotoCatalogIndex` já ignora writes do lote via `context.faceIndex` (`ArchivePhoto.ts:168-172`) — manter.
|
||||
- Produção verificada 2026-10-01: `archive_photo` 0 aprovadas / 6.492 draft; `face_subject` 0; 0 consentimentos; flag default off; `photo_album` sem linha (defaults: published=true, selfie=false, sem canal de remoção).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph OPS["Ops — CLI"]
|
||||
PUB["pnpm archive:publish --apply\naprova drafts em lote"] --> APR[("archivePhoto\napproved")]
|
||||
IDX["pnpm faces:index --apply"] --> DLD["downloadPrivateMediaToFile + sharp ≤1024px"]
|
||||
DLD --> NODE["engine node-wasm (face-api)"]
|
||||
NODE --> ROW[("archivePhotoFace\nphoto · vector 128d · model")]
|
||||
APR --> IDX
|
||||
CANAL["canal de remoção no global"] --> PUB
|
||||
end
|
||||
subgraph PUB2["Público"]
|
||||
ALB["/fotos + seção da home (C243)"] --> ENC["/fotos/encontre\nflag + aviso + consentimento"]
|
||||
ENC --> BRW["engine on-device · selfie NÃO sai"]
|
||||
BRW -- "vetor 128d ≤4KB" --> API["POST /api/fotos/selfie"]
|
||||
API --> CACHE["índice de descritores em memória (TTL 60s)"]
|
||||
CACHE --> APR2["getApprovedArchivePhotoItems ∩ matches\nsem nomes · sem score · ≤60"]
|
||||
API -- "leave-index" --> DEL["apaga descritores do rosto"]
|
||||
UNAP["desaprovar/remover foto"] --> PURGE["hook apaga descritores da foto"]
|
||||
end
|
||||
```
|
||||
|
||||
**Opções consideradas (índice B):**
|
||||
A) collection `archivePhotoFace` (1 linha/rosto, `json` 128 floats) + ranking em memória com cache curto; B) coluna json no próprio `archivePhoto`; C) pgvector/serviço externo.
|
||||
**Recomendação:** **A** — segue o precedente C229 (`speechEmbedding` jsonb + ranking em memória), isola PII biométrica da ficha pública, permite apagar por rosto/foto e não exige extensão de banco (o Postgres do homeserver é vanilla; C229 rejeitou pgvector).
|
||||
**Rejeitadas:** **B** porque carregaria ~6,5k documentos com vetores a cada scan e misturaria biometria na ficha do acervo; **C** porque troca imagem do Postgres/instala extensão por ganho marginal na escala atual (~dezenas de milhares de rostos).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Schema**
|
||||
|
||||
- **`src/collections/ArchivePhotoFace.ts`** (novo): slug `archivePhotoFace`, labels “Rosto no índice”/“Rostos no índice”, grupo `Comunicação`, `admin.hidden: true` (dado de máquina; sem UI), access `payloadAdminOnly` nas quatro operações. Campos: `photo` (relationship → `archivePhoto`, required, index), `model` (text, required, index), `vector` (json, hidden, readOnly), `detectedAt` (date, readOnly). Sem hooks.
|
||||
- **`src/payload.config.ts`**: registrar a collection; remover `FaceSubject`.
|
||||
- **`src/collections/FaceSubject.ts`**: **deletar**.
|
||||
- **Migration `add_archive_photo_face`** (gerada + revisada à mão): cria `archive_photo_face` (+ FK para `archive_photo` com `ON DELETE CASCADE` e índice por `photo`), dropa `face_subject`, `face_subject_rels`, `enum_face_subject_status` (0 linhas em produção). Nunca editar migrations antigas.
|
||||
- **`src/collections/ArchivePhoto.ts`**: hook novo `afterChange` que apaga descritores quando a foto deixa de ser `approved` (e `afterDelete` equivalente); o batch continua marcando `faces.checkedAt/checkedKey` com `context.faceIndex`.
|
||||
|
||||
**Contrato puro (`src/lib/faceSearch.ts`, editar)**
|
||||
|
||||
- Manter descritor/modelo/threshold/intents/view model/stub.
|
||||
- Novo `findFaceDescriptorMatch({ vector, descriptors, model })` → melhores `photoIds` distintos sob o threshold (nunca devolve distância).
|
||||
- Remover `findFaceMatch`/`faceSubjectIsEligible`/`FaceSearchSubject` (sem subjects).
|
||||
|
||||
**Índice (novo domínio `src/utilities/faceIndex/`, substitui `faceSubjects/`)**
|
||||
|
||||
- `faceDescriptorIndex.ts` (`server-only`): `listArchivePhotoFaceIndexQueue({ payload, refresh, limit })` (aprovadas + marker), `indexArchivePhotoFaces({ payload, item, indexKey, analyze })` — baixa/prepara/analisa e, em uma transação, **substitui** os descritores da foto e grava o marker `sha256(model)`. Falha por foto é resultado, nunca throw (estágios download/image/analyze/write).
|
||||
- `faceDescriptorReads.ts` (`server-only`): `loadFaceDescriptorIndex(payload)` com cache em memória (TTL 60 s, `Float32Array` por descritor, cap defensivo documentado) e `deleteFaceDescriptorMatches({ payload, vector, model })` para o opt-out; leitura direta (sem cache) no delete para o opt-out valer na hora.
|
||||
- Deletar `src/utilities/faceSubjects/*` e `scripts/enroll-face-subject.mjs`; remover `faces:enroll` do `package.json`; ajustar `scripts/copy-face-vision-assets.mjs` só se referenciar o CLI (não referencia).
|
||||
|
||||
**Endpoint (`src/app/(frontend)/api/fotos/selfie/route.ts`, editar)**
|
||||
|
||||
- Mesmas recusas (400 body, 403 origin, 404 flag/álbum, 429 rate, 503 consent) — muda o miolo: exige os dois Consents (`busca-selfie-fotos` consulta + `busca-selfie-indice` aviso); match contra `loadFaceDescriptorIndex`; `leave-index` apaga os descritores que casarem; resposta `getApprovedArchivePhotoItems ∩ photoIds` (≤60, `toFaceSearchPhotoView`).
|
||||
- Página `/fotos/encontre`: passa a exigir/consumir também o aviso (renderiza o texto do aviso + texto do consentimento) e mantém o estado fechado sem qualquer um dos dois; `/fotos` mostra a entrada quando flag + aprovadas (e aviso/consentimento configurados).
|
||||
|
||||
**Copy/estados (porta do design estendido no C243)**
|
||||
|
||||
- `SelfieSearchEntry`: “a busca cobre quem autorizou participar do índice” → cobre o acervo aprovado, com saída do índice.
|
||||
- `SelfieSearchFlow`: textos de consentimento/aviso e o estado de “Minha presença” (o vetor/descritores do rosto são apagados do índice).
|
||||
|
||||
**CLI de publicação (novo `scripts/publish-archive-photos.mjs`, guardas C231/C232)**
|
||||
|
||||
- Dry-run default; `--apply` exige `ARCHIVE_PUBLISH_CONFIRM=1`, `TEQO_ENV` casando o banco e as 4 `S3_*`; `--limit`, `--refresh`; nunca toca `removed`; exige `removalChannelUrl` válido (o guard da collection reforça); recibo JSON em `data/archive/reports/`; ao fim, instrui/deposita o bust da tag `archivePhotos` via `POST /api/revalidate` (writes fora do processo Next não revalidam sozinhos).
|
||||
- `package.json`: script `archive:publish`.
|
||||
|
||||
**Testes**
|
||||
|
||||
- Unit: `faceSearch` (match por descritores, threshold estrito, sem score), parser do CLI de publicação, plan/CLI do índice.
|
||||
- Int: `faceSearchApi` (busca sem subject, opt-out, flag/álbum/Consents, rate limit, 404/503) e `faceDescriptorIndex` (substituição por foto, purge em desaprovação/delete, marker).
|
||||
- E2e: `frontendFotosSelfie` (semear descritores via fixture em vez de subject; fluxo completo com stub), seção da home (C243).
|
||||
- Fixtures/helpers: `tests/helpers/faceIntFixtures.ts` reescrito para descritores; ownership de `archivePhotoFace`.
|
||||
|
||||
**Ops (runbook §C234 reescrito)**
|
||||
|
||||
1. Admin: criar os dois Consentimentos (textos da entrega) e o canal de remoção no global Álbum de fotos.
|
||||
2. `pnpm archive:publish --apply` (6.492) + bust da tag; conferir `/fotos`.
|
||||
3. `pnpm faces:index --apply` (horas; canário `--limit 20` antes) + `--verify`.
|
||||
4. Ligar `Busca por selfie`; conferir `/fotos/encontre` e a seção da home.
|
||||
Rollback: desligar a flag; migration do índice é aditiva exceto o drop do `face_subject` (vazio).
|
||||
|
||||
**Docs**
|
||||
|
||||
- `AGENTS-public.md` (contrato `/fotos/encontre` reescrito para escopo B), `docs/ops/teqo-1313-deploy.md` §C234, `docs/changelog/<data>-c240-c241.md`, planos atualizados (C234 ganha nota de superação).
|
||||
|
||||
## Riscos
|
||||
|
||||
- **Falso positivo do threshold** mostra foto de outra pessoa — mitigado por threshold conservador, canário e calibração no runbook; sem score na UI.
|
||||
- **Custo do lote** (~6,5k detecções) — ops em background no homeserver; retomável pelo marker.
|
||||
- **Descrição/volume do índice em memória** — `Float32Array`, TTL curto e cap documentado; degrada para vazio honesto se o índice não carregar.
|
||||
- **Migração destrutiva** do `face_subject` (vazio em produção; staging/local com dados de teste podem perder linhas — aceitável e registrado).
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Collection + migration + purge hooks + types
|
||||
- [ ] Domínio `faceIndex` (batch/reads/delete) + CLI `faces:index` novo + remoção do enrollment
|
||||
- [ ] Endpoint + páginas fail-closed (consulta + aviso)
|
||||
- [ ] CLI `archive:publish` + script
|
||||
- [ ] Copy/estados do fluxo e da entrada `/fotos` (porta do design)
|
||||
- [ ] Testes unit/int/e2e + fixtures
|
||||
- [ ] Docs (AGENTS-public, runbook, changelog, planos)
|
||||
- [ ] Ops: consentimentos, canal, lote, indexação, flag
|
||||
92
docs/plans/busca-selfie-escopo-b.md
Normal file
92
docs/plans/busca-selfie-escopo-b.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# C242 — Busca por selfie aberta a qualquer visitante (escopo B)
|
||||
|
||||
Status: aprovado (decisão do dono na sessão, 2026-10-01)
|
||||
Atualizado em: 2026-10-01
|
||||
Issue: — (worktree `work/43`, sem claim)
|
||||
Priority: P1
|
||||
Impeccable: C — superfície pública de biometria (muda o escopo do índice facial do C234)
|
||||
Design UI: docs/plans/busca-fotos-por-selfie-ui-design.html (estender no C243; esta fatia muda copy/estados do fluxo, não a gramática visual)
|
||||
Appetite: ~2–3 dias eng + ops de publicação/indexação; um outcome verificável — **qualquer visitante** encontra no acervo público aprovado as fotos em que aparece, sem que a selfie saia do aparelho e sem score
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O C234 entregou a busca por selfie **fechada por padrão** e no escopo A/C: só quem foi inscrito pela assessoria (`pnpm faces:enroll`, consentimento assinado) entra no índice; um visitante comum nunca se encontra. O dono decidiu (2026-10-01), com aval jurídico declarado na sessão, **reabrir o escopo do gate (PR #1370) para B**: indexar os rostos presentes no acervo público aprovado, anonimamente (sem nome, sem vínculo com pessoa), para que **qualquer visitante** consiga se achar. A base de fotos também nunca foi publicada (6.492 drafts, 0 aprovadas) — sem isso não há o que buscar —, então a fatia inclui a publicação em lote do acervo e a indexação.
|
||||
|
||||
O que muda de conceito: o índice deixa de ser "adesão de pessoas" e passa a ser **índice biométrico anônimo do acervo aprovado**, com transparência pública (aviso), remoção a pedido (opt-out por selfie + canal de remoção de foto) e kill switch imediato. O enrollment por CLI e a collection `faceSubject` ficam obsoletos e são removidos (editar o dono, não criar gêmeo).
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** visitante que foi a um evento com Solla, no celular, sem conta na campanha.
|
||||
- **Job principal:** encontrar, entre as fotos públicas aprovadas, as que eu apareço.
|
||||
- **Fluxo desejado:** abre `/fotos` (ou a seção da home) → toca "Encontre você nas fotos" → lê o aviso (biometria/LGPD + como sair do índice) e consente com o uso da própria selfie → tira/envia uma selfie → o reconhecimento roda no dispositivo (a selfie não sobe) → vê só as fotos aprovadas em que aparece, sem nome de terceiros e sem score → a qualquer momento pede para sair do índice (selfie de novo → o rosto deixa de ser encontrado) e/ou despublica uma foto pelo canal de remoção.
|
||||
- **Anti-goals de produto:** não é vigilância nem CRM de rostos; não nomeia nem identifica terceiros; não cruza com `Contact`/leadership; não oferece busca por outra pessoa; não abre sem aviso/consentimento configurados; não retém a selfie.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Com o aviso e o consentimento de consulta configurados e a flag ligada, **qualquer visitante** que apareça em foto aprovada recebe a lista dessas fotos.
|
||||
- Sem aviso/consentimento configurado (ou flag desligada), o fluxo recusa com linguagem clara — nunca meio aberto, nem na UI nem no servidor.
|
||||
- Nenhum rastro da selfie no servidor: o browser envia só o vetor 128-d; nenhuma imagem, nenhum score, nenhum nome de terceiro.
|
||||
- Nenhuma foto draft/removida no resultado; desaprovar uma foto a tira da busca imediatamente (por construção: o resultado cruza o índice com o read aprovado do C233, e os descritores da foto são apagados na desaprovação/remoção).
|
||||
- Opt-out funciona: "Minha presença" com a selfie apaga os descritores daquele rosto do índice e ele deixa de ser encontrado; o pedido de remoção de foto continua no canal do álbum.
|
||||
- O índice cobre só o acervo **aprovado** e só o modelo vigente; trocar o modelo invalida a revisão e o próximo lote reprocessa.
|
||||
- Antiabuso: rate limit anônimo (20/10 min por IP hasheado), same-origin, body ≤4 KB.
|
||||
- **Guardrails:** Consent/LGPD fail-closed (aviso + consentimento de consulta são o gate); sem cruzamento com `Contact`/leadership; a biometria não vira cadastro de pessoa.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — o resultado é "quais fotos", nunca "quão parecido". Score/percentual é anti-goal explícito do dono.
|
||||
- **Decisões desbloqueadas:** N/A — a escolha do visitante ("essa foto é minha, quero guardar/compartilhar") é qualitativa.
|
||||
- **Forma:** N/A — **sem score/percentual** de semelhança em nenhum estado; a única saída é a lista de fotos.
|
||||
|
||||
## Dados da decisão (literais)
|
||||
|
||||
- **Escopo do índice — decisão do dono (2026-10-01): B.** Indexa os rostos do acervo público aprovado, anonimamente; a resposta é só sobre a própria pessoa consultante. O DPIA/aval jurídico foi **declarado aprovado pelo dono na sessão** ("o jurídico aprovou; qualquer coisa ajeitamos ou tiramos do ar depois"); o fail-closed de configuração (aviso + consentimento) permanece no código.
|
||||
- **Textos:** os dois Consentimentos (`busca-selfie-fotos` de consulta e `busca-selfie-indice`, reclassificado como **aviso público do índice**) são redigidos nesta entrega e configurados no admin antes da abertura; nada abre sem eles.
|
||||
- **Selfie nunca sai do dispositivo:** o browser roda o engine e envia só o vetor; sem upload, sem galeria, sem retenção.
|
||||
- **Resposta mínima:** só fotos aprovadas em que a pessoa consultante aparece; nunca "outras pessoas parecidas", nunca nome de terceiro, nunca score.
|
||||
- **Remoção:** opt-out por selfie apaga os descritores do rosto no índice; foto específica sai pelo canal de remoção do álbum (`photoAlbum.removalChannelUrl`, obrigatório).
|
||||
- **Publicação do acervo:** as 6.492 fotos draft são aprovadas em lote (decisão do dono, 2026-10-01), com guardas de ops; `removed` nunca é reaprovada por lote.
|
||||
- **Enrollment A/C morre:** `faceSubject`, `pnpm faces:enroll` e a adesão por pessoa são removidos; a chave `busca-selfie-indice` passa a nomear o aviso do índice.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/collections/ArchivePhoto.ts` (grupo `faces` existente), collection nova do índice (`archivePhotoFace`), `src/utilities/faceSubjects/*` (reescrito como domínio `faceIndex`), `src/lib/faceSearch.ts` (match por descritor do acervo), `src/app/(frontend)/api/fotos/selfie/route.ts`, `scripts/index-archive-faces.mjs` (+ novo CLI de publicação), migration.
|
||||
- **Precedente a olhar:** C229 (`speechEmbeddingIndex.ts`/`speechEmbedding` — índice vetorial em jsonb com ranking em memória), `archivePhotoReads.ts` (read público aprovado + tag), `withPayloadTransaction`, guardas de CLI do C231/C232, `revalidateRequest` (`/api/revalidate` para writes fora do processo Next).
|
||||
- **Risco de acoplamento:** a busca hoje resolve `faceSubject`; remover a collection exige migração destrutiva (tabela vazia em produção — verificado: 0 linhas) e atualização de testes/fixtures; o endpoint e o e2e mudam de "seed de subject" para "seed de descritores".
|
||||
|
||||
## Dependências
|
||||
|
||||
- Duras: C231/C232/C233 (acervo, catalogação, álbum público) — entregues.
|
||||
- Duras de produto: aval jurídico/DPIA — **declarado aprovado pelo dono na sessão de 2026-10-01**; registrar no changelog.
|
||||
- Soft: C243 (seção na home) consome o estado "busca aberta" daqui.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Identificação/nomeação de terceiros; diretório de rostos; busca por outra pessoa; indexação de vídeo; API pública de reconhecimento.
|
||||
- Retenção de selfie/imagem; score; galeria.
|
||||
- Curadoria foto a foto do acervo (o lote publica tudo, com canal de remoção).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Só completar" indexando vídeo/gravações.** **Corte:** só fotos aprovadas do acervo.
|
||||
- **"Só completar" nomeando quem aparece.** **Corte:** nunca nome inferido de rosto; `catalog.people` continua vindo só do catálogo curado e por texto.
|
||||
- **"Só completar" com score/"match 87%".** **Corte:** sem número em nenhum estado.
|
||||
- **"Só completar" guardando a selfie.** **Corte:** vetor-only, sem retenção.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Calibração do threshold (0.45) para índice anônimo:** risco assimétrico muda (falso positivo mostra foto de outra pessoa). **Recomendação:** manter 0.45 no v1 e calibrar com o canário/amostra no runbook; número nunca aparece na UI.
|
||||
- **Quem atende a fila de remoção de foto:** comunicação/assessoria, como no C233 (canal de remoção já é dela).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/plans/busca-fotos-por-selfie.md` (C234, escopo A/C — decisão superada por este item)
|
||||
- `docs/plans/busca-fotos-por-selfie-impl.md` (dono técnico a editar)
|
||||
- `docs/plans/album-fotos-publico-busca.md` (C233 — álbum e canal de remoção)
|
||||
- `docs/ops/teqo-1313-deploy.md` §C234 (runbook a reescrever)
|
||||
- `docs/changelog/2026-09-27-c234.md` (entrega anterior)
|
||||
|
||||
## Self-score (shaping)
|
||||
|
||||
5/5 — (1) fatia = um outcome verificável (qualquer visitante se acha no acervo aprovado; sem configuração, recusa); (2) appetite comporta schema + CLI + endpoint + remoção + ops, sem pesquisa nova; (3) persona, job e aceite verificáveis; (4) direção no codebase com precedentes nomeados; (5) decisões duras (escopo/legal) tomadas pelo dono e registradas.
|
||||
35
docs/plans/busca-selfie-secao-home-impl.md
Normal file
35
docs/plans/busca-selfie-secao-home-impl.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Impl: 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)
|
||||
Intenção: docs/plans/busca-selfie-secao-home.md
|
||||
Design UI (gate): docs/plans/busca-selfie-secao-home-ui-design.html
|
||||
Appetite restante: herdado (~0,5–1 dia eng) — uma seção condicional na home, sem engine.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** visitante descobre a busca na home e chega a `/fotos/encontre`; fechada, a seção não aparece (sem CTA morto).
|
||||
- **O que NÃO negociar:** sem captura/engine na home; sem promessa de "todas as fotos"/score; mobile-first; nada de fotos do acervo renderizadas na home; gate só com reads cacheados.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
**Opções consideradas (gate):** A) gating por `photoAlbum` cacheado (published + flag) + `hasPublishedArchivePhotos()` (também cacheado); B) leitura live dos Consents para o gate (mais exato, quebra a estaticidade/ISR da home).
|
||||
**Recomendação:** **A** — a home mantém os reads cacheados; flag desligada já esconde a seção, e a combinação flag ligada + Consent ausente (misconfiguração) cai no estado fechado honesto da página de destino, nunca em 404 (a página `/fotos/encontre` existe nesse caso).
|
||||
**Rejeitadas:** **B** porque uma leitura live de Consent por request transformaria a homepage (hoje estática com ISR por tags) em dinâmica sem ganho de segurança material — o servidor da busca é o gate real.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/components/fotos/SelfieSearchHomeSection.tsx`** (novo): porta classe-a-classe de `docs/plans/busca-selfie-secao-home-ui-design.html`; título, explicação curta (selfie não sai do aparelho; acervo aprovado; saída do índice) e CTAs (`Encontre você nas fotos` → `/fotos/encontre`; link secundário `Ver o álbum` → `/fotos`).
|
||||
- **`src/app/(frontend)/(home)/page.tsx`**: somar `photoAlbum` (via `getCachedGlobal`) ao `Promise.all`; renderizar a seção quando `published !== false && selfieSearchEnabled === true && showFotos` (mesmo `showFotos` já lido de `hasPublishedArchivePhotos`).
|
||||
- **`src/components/fotos/SelfieSearchEntry.tsx`** (C242, ajuste de copy): atualizar o texto do escopo B quando o design estendido definir os literais.
|
||||
- **Testes:** e2e da home (seção ausente com flag off/álbum fechado; presente com flag on + fotos aprovadas; CTA leva à página da busca) no spec existente de fotos/selfie ou no de frontend, seguindo o manifest curado; unit de gating se extrair predicado puro.
|
||||
- **Docs:** changelog conjunto C242/C243; `AGENTS-public.md` ganha a seção.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Design hi-fi aprovado (agente `designer`) gravado no disco
|
||||
- [ ] Componente + composição na home + gating
|
||||
- [ ] Copy da entrada `/fotos` alinhada ao escopo B
|
||||
- [ ] Testes (unit/e2e) e manifest
|
||||
- [ ] Docs/changelog
|
||||
481
docs/plans/busca-selfie-secao-home-ui-design.html
Normal file
481
docs/plans/busca-selfie-secao-home-ui-design.html
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR" data-theme="campaign-site">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#fff8f2" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>Design — C243 · Busca por selfie na homepage</title>
|
||||
<style type="text/tailwindcss">
|
||||
@theme inline {
|
||||
--font-sans: Inter, Arimo, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme='campaign-site'] {
|
||||
--pt-red: #a21c1c;
|
||||
--pt-red-dark: #47130e;
|
||||
--pt-yellow: #ffe607;
|
||||
--campaign-cream: #fff8f2;
|
||||
--campaign-ink: #000000;
|
||||
--campaign-muted: #6c615e;
|
||||
--campaign-surface: #f9faff;
|
||||
--campaign-band: #ebe9e9;
|
||||
--campaign-line: rgb(0 0 0 / 12%);
|
||||
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, Arimo, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: 'Exo 2', Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.campaign-section-eyebrow {
|
||||
font-size: clamp(10px, calc(0.32vw + 8.75px), 12px);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.campaign-section-title {
|
||||
font-size: clamp(28px, calc(1.15vw + 23px), 38px);
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.campaign-section-copy {
|
||||
font-size: clamp(15px, calc(0.32vw + 13.75px), 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.stage {
|
||||
box-shadow: 0 24px 80px rgb(32 27 26 / 15%);
|
||||
}
|
||||
|
||||
.archive-focus:focus-visible {
|
||||
outline: 3px solid var(--pt-yellow);
|
||||
outline-offset: 3px;
|
||||
box-shadow: 0 0 0 6px rgb(24 78 146 / 72%);
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--pt-red);
|
||||
padding: 10px 16px;
|
||||
color: white;
|
||||
font-family: 'Exo 2', Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 0 #7f1616;
|
||||
transition:
|
||||
transform 150ms ease-out,
|
||||
box-shadow 150ms ease-out;
|
||||
}
|
||||
|
||||
.primary-action:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.primary-action:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 2px 0 #7f1616;
|
||||
}
|
||||
|
||||
.secondary-link {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--pt-red);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.secondary-link:hover {
|
||||
color: #741414;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
a {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: rgb(162 28 28 / 18%);
|
||||
}
|
||||
|
||||
.assurance-mark {
|
||||
background:
|
||||
radial-gradient(circle at 76% 18%, rgb(255 230 7 / 66%) 0 8%, transparent 8.5%),
|
||||
linear-gradient(145deg, #f9faff 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.primary-action {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.primary-action:hover,
|
||||
.primary-action:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
[data-theme='campaign-site'] {
|
||||
--campaign-content-inset: max(180px, calc((100vw - 1152px) / 2 + 32px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-[#efeeec] text-[#201b1a] antialiased">
|
||||
<svg class="absolute size-0 overflow-hidden" aria-hidden="true">
|
||||
<symbol id="spark" viewBox="0 0 24 24">
|
||||
<path d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z" />
|
||||
<path d="m19 14-.9 2.1L16 17l2.1.9L19 20l.9-2.1L22 17l-2.1-.9L19 14Z" />
|
||||
</symbol>
|
||||
<symbol id="device" viewBox="0 0 24 24">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" />
|
||||
<path d="M9 18h6" />
|
||||
</symbol>
|
||||
<symbol id="images" viewBox="0 0 24 24">
|
||||
<path d="m16 5 3 3M4 9V5a2 2 0 0 1 2-2h9l5 5v10a2 2 0 0 1-2 2h-4" />
|
||||
<rect x="2" y="11" width="12" height="10" rx="2" />
|
||||
<circle cx="6" cy="15" r="1" />
|
||||
<path d="m4 19 3-3 5 5" />
|
||||
</symbol>
|
||||
<symbol id="shield" viewBox="0 0 24 24">
|
||||
<path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V5l8-3 8 3v8Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</symbol>
|
||||
<symbol id="arrow" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6" /></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="mx-auto flex max-w-[1480px] flex-col gap-14 px-4 py-10 md:px-8">
|
||||
<header class="max-w-4xl">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="rounded-full bg-[#a21c1c] px-3 py-1 text-xs font-extrabold text-white"
|
||||
>C243 · gate hi-fi</span
|
||||
>
|
||||
<span class="rounded-full border border-black/10 bg-white px-3 py-1 text-xs font-bold"
|
||||
>Homepage pública · aberta</span
|
||||
>
|
||||
<span
|
||||
class="rounded-full border border-[#184e92]/20 bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
|
||||
>Sem selfie, engine, fotos ou contadores na home</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="display mt-4 text-3xl font-black tracking-[-0.03em]">
|
||||
A home apresenta a busca; a experiência sensível começa só em /fotos/encontre
|
||||
</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-[#6c615e]">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="desktop-title">
|
||||
<p class="text-xs font-extrabold tracking-[0.1em] text-[#a21c1c] uppercase">
|
||||
Cena 01 · 1280
|
||||
</p>
|
||||
<h2 id="desktop-title" class="display mt-1 text-xl font-black">
|
||||
Entre a Central de Conteúdos e a newsletter
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-[#6c615e]">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-black/10 bg-white">
|
||||
<div class="min-w-[1280px] bg-white">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex h-20 items-center justify-center border-b border-black/10 bg-white text-xs font-bold tracking-[0.08em] text-black/45 uppercase"
|
||||
>
|
||||
fim da seção · Central de Conteúdos
|
||||
</div>
|
||||
|
||||
<!-- Port classe-a-classe: section border-b bg-(--campaign-cream); container
|
||||
max-w-[1160px] px-5 sm:px-8 lg:px-10 py-12 lg:py-16. -->
|
||||
<section
|
||||
aria-labelledby="selfie-home-desktop-title"
|
||||
class="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/45"
|
||||
></div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -bottom-32 left-[36%] size-64 rounded-full bg-(--pt-red)/8"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="relative mx-auto grid w-full max-w-[1160px] grid-cols-[minmax(0,1.1fr)_minmax(360px,.9fr)] items-center gap-16 px-10 py-16"
|
||||
>
|
||||
<div class="max-w-[620px]">
|
||||
<p
|
||||
class="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase"
|
||||
>
|
||||
Fotos da nossa caminhada
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-home-desktop-title"
|
||||
class="campaign-section-title display m-0 mt-2 max-w-[560px] border-0 p-0 font-black tracking-[-0.03em] text-balance"
|
||||
>
|
||||
Encontre você nas fotos
|
||||
</h2>
|
||||
<p class="campaign-section-copy mt-4 max-w-[600px] text-(--campaign-muted)">
|
||||
Use uma selfie sua na página da busca para procurar, entre as fotos públicas
|
||||
aprovadas, aquelas em que você aparece.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex max-w-[610px] items-start gap-3 text-sm leading-6">
|
||||
<div
|
||||
class="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
|
||||
>
|
||||
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
|
||||
<use href="#shield" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="m-0 text-[#365472]">
|
||||
<b class="text-[#143c70]">A selfie não sai do seu aparelho.</b> A busca cobre
|
||||
o índice anônimo do acervo aprovado, e você pode retirar seu rosto quando
|
||||
quiser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<a href="/fotos/encontre" class="primary-action archive-focus">
|
||||
Encontrar minhas fotos
|
||||
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
|
||||
<use href="#arrow" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/fotos" class="secondary-link archive-focus">
|
||||
Ver o álbum
|
||||
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
|
||||
<use href="#arrow" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="assurance-mark relative overflow-hidden rounded-2xl border border-[#184e92]/15 p-6"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute top-5 right-5 grid size-12 place-items-center rounded-full bg-white text-[#184e92] shadow-[0_8px_28px_rgb(24_78_146/12%)]"
|
||||
>
|
||||
<svg aria-hidden="true" class="size-6 fill-none stroke-current stroke-2">
|
||||
<use href="#spark" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="display max-w-[250px] text-xl leading-tight font-black text-[#143c70]">
|
||||
Sua imagem fica com você. O controle também.
|
||||
</p>
|
||||
<ul class="mt-7 grid list-none gap-0 p-0">
|
||||
<li class="flex items-center gap-3 border-t border-[#184e92]/12 py-4">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 fill-none stroke-[#184e92] stroke-2"
|
||||
>
|
||||
<use href="#device" />
|
||||
</svg>
|
||||
<span class="text-sm font-bold text-[#143c70]"
|
||||
>Processamento no aparelho</span
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="ml-auto size-4 fill-none stroke-[#184e92] stroke-2"
|
||||
>
|
||||
<use href="#check" />
|
||||
</svg>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 border-t border-[#184e92]/12 py-4">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 fill-none stroke-[#184e92] stroke-2"
|
||||
>
|
||||
<use href="#images" />
|
||||
</svg>
|
||||
<span class="text-sm font-bold text-[#143c70]"
|
||||
>Só o acervo público aprovado</span
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="ml-auto size-4 fill-none stroke-[#184e92] stroke-2"
|
||||
>
|
||||
<use href="#check" />
|
||||
</svg>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 border-y border-[#184e92]/12 py-4">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 fill-none stroke-[#184e92] stroke-2"
|
||||
>
|
||||
<use href="#shield" />
|
||||
</svg>
|
||||
<span class="text-sm font-bold text-[#143c70]"
|
||||
>Saída do índice a qualquer momento</span
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="ml-auto size-4 fill-none stroke-[#184e92] stroke-2"
|
||||
>
|
||||
<use href="#check" />
|
||||
</svg>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border-b border-black/10 bg-(--campaign-band) px-10 py-10 text-center">
|
||||
<p
|
||||
class="campaign-section-eyebrow font-black tracking-[0.1em] text-(--pt-red) uppercase"
|
||||
>
|
||||
Apoie e acompanhe
|
||||
</p>
|
||||
<p class="display mt-1 text-2xl font-black">Receba as novidades da campanha</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="mobile-title">
|
||||
<p class="text-xs font-extrabold tracking-[0.1em] text-[#a21c1c] uppercase">
|
||||
Cena 02 · 390
|
||||
</p>
|
||||
<h2 id="mobile-title" class="display mt-1 text-xl font-black">
|
||||
Mobile-first · uma ação dominante, sem simular captura
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-black/10 bg-white"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex h-16 items-center justify-center border-b border-black/10 bg-white text-[10px] font-bold tracking-[0.08em] text-black/40 uppercase"
|
||||
>
|
||||
fim da Central de Conteúdos
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="selfie-home-mobile-title"
|
||||
class="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -top-14 -right-14 size-40 rounded-full bg-(--pt-yellow)/45"
|
||||
></div>
|
||||
<div class="relative px-5 py-12">
|
||||
<p
|
||||
class="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase"
|
||||
>
|
||||
Fotos da nossa caminhada
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-home-mobile-title"
|
||||
class="display m-0 mt-2 max-w-[320px] border-0 p-0 text-[30px] leading-[1.02] font-black tracking-[-0.03em] text-balance"
|
||||
>
|
||||
Encontre você nas fotos
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] leading-6 text-(--campaign-muted)">
|
||||
Use uma selfie sua na página da busca para procurar as fotos públicas aprovadas em
|
||||
que você aparece.
|
||||
</p>
|
||||
|
||||
<div class="assurance-mark mt-6 rounded-2xl border border-[#184e92]/15 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="grid size-10 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
|
||||
>
|
||||
<svg aria-hidden="true" class="size-5 fill-none stroke-current stroke-2">
|
||||
<use href="#shield" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-extrabold text-[#143c70]">Sua selfie fica no aparelho</p>
|
||||
<p class="mt-1 text-xs leading-5 text-[#365472]">
|
||||
A selfie não sai deste aparelho. A busca cobre o acervo aprovado, e você pode
|
||||
retirar seu rosto do índice quando quiser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/fotos/encontre" class="primary-action archive-focus mt-6 w-full">
|
||||
Encontrar minhas fotos
|
||||
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
|
||||
<use href="#arrow" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/fotos" class="secondary-link archive-focus mt-2 w-full justify-center">
|
||||
Ver o álbum
|
||||
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
|
||||
<use href="#arrow" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bg-(--campaign-band) px-5 py-9 text-center">
|
||||
<p
|
||||
class="campaign-section-eyebrow font-black tracking-[0.1em] text-(--pt-red) uppercase"
|
||||
>
|
||||
Apoie e acompanhe
|
||||
</p>
|
||||
<p class="display mt-1 text-[22px] leading-tight font-black">
|
||||
Receba as novidades da campanha
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="rules-title"
|
||||
class="max-w-4xl rounded-2xl border border-black/10 bg-white p-6"
|
||||
>
|
||||
<p class="text-xs font-extrabold tracking-[0.1em] text-[#a21c1c] uppercase">
|
||||
Contrato do port
|
||||
</p>
|
||||
<h2 id="rules-title" class="display mt-1 text-xl font-black">Estados e acessibilidade</h2>
|
||||
<ul class="mt-4 grid list-none gap-3 p-0 text-sm leading-6 text-[#6c615e] sm:grid-cols-2">
|
||||
<li class="rounded-xl bg-[#f9faff] p-4">
|
||||
<b class="text-[#201b1a]">Fechada:</b> 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.
|
||||
</li>
|
||||
<li class="rounded-xl bg-[#f9faff] p-4">
|
||||
<b class="text-[#201b1a]">Hierarquia:</b> o título da seção é <code>h2</code>; há um
|
||||
único CTA preenchido. “Ver o álbum” permanece secundário, textual e com alvo mínimo de
|
||||
44 px.
|
||||
</li>
|
||||
<li class="rounded-xl bg-[#f9faff] p-4">
|
||||
<b class="text-[#201b1a]">Foco:</b> 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.
|
||||
</li>
|
||||
<li class="rounded-xl bg-[#f9faff] p-4">
|
||||
<b class="text-[#201b1a]">Movimento:</b> só o CTA reage ao hover/press; em
|
||||
<code>prefers-reduced-motion: reduce</code>, transição e deslocamento são removidos.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
76
docs/plans/busca-selfie-secao-home.md
Normal file
76
docs/plans/busca-selfie-secao-home.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "<nome>" --selfie <arquivo>
|
||||
* 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 <id> (re-enrollment: replaces the descriptor, advances
|
||||
* `enrolledAt` and clears the derived links), --out <dir> (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 "<nome>" --selfie <arquivo> [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> nome interno da pessoa (nunca aparece no site)
|
||||
--selfie <arquivo> foto com um único rosto
|
||||
--subject <id> re-enrollment: substitui o vetor e limpa os vínculos derivados
|
||||
--out <dir> 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 "<nome>" --selfie <arquivo> --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))
|
||||
})
|
||||
|
|
@ -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 <n> (canary), --refresh (ignores the marker), --out <dir>
|
||||
* (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 <n> --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,
|
||||
|
|
|
|||
83
scripts/lib/archivePublishPlan.mjs
Normal file
83
scripts/lib/archivePublishPlan.mjs
Normal file
|
|
@ -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 <n>] [--out <dir>]`'
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
52
scripts/lib/faceConsentTexts.mjs
Normal file
52
scripts/lib/faceConsentTexts.mjs
Normal file
|
|
@ -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)
|
||||
|
|
@ -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 "<nome>" --selfie <arquivo> [--subject <id>] [--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
|
||||
}
|
||||
|
|
@ -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 ?? []) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
213
scripts/publish-archive-photos.mjs
Normal file
213
scripts/publish-archive-photos.mjs
Normal file
|
|
@ -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 <n> (canary), --out <dir> (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 <n> processa no máximo n fotos (canário)
|
||||
--out <dir> 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))
|
||||
})
|
||||
159
scripts/seed-face-consents.mjs
Normal file
159
scripts/seed-face-consents.mjs
Normal file
|
|
@ -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))
|
||||
})
|
||||
|
|
@ -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() {
|
|||
<JingleHomeSection jingles={homeJingles} showAll={jingles.length > homeJingles.length} />
|
||||
<CampaignCardsSection />
|
||||
{contentPieces.length > 0 ? <ContentPieceHomeSection items={contentPieces} /> : null}
|
||||
{showSelfieSearch ? <SelfieSearchHomeSection /> : null}
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<NextResponse> => {
|
|||
}
|
||||
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
{consent ? (
|
||||
{consent && indexNotice ? (
|
||||
<SelfieSearchFlow
|
||||
consentText={<ConsentText data={consent.text} />}
|
||||
noticeText={<ConsentText data={indexNotice.text} />}
|
||||
removalChannelUrl={removalChannelUrl}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
79
src/collections/ArchivePhotoFace.ts
Normal file
79
src/collections/ArchivePhotoFace.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -24,11 +24,12 @@ export const SelfieSearchEntry = () => (
|
|||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-(--campaign-muted)">
|
||||
<span className="sm:hidden">
|
||||
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.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
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.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<FlowStep>({ kind: 'consent' })
|
||||
|
|
@ -234,8 +236,34 @@ export const SelfieSearchFlow = ({
|
|||
>
|
||||
Sua face é um dado biométrico sensível
|
||||
</h2>
|
||||
<div className="mt-3 max-h-72 overflow-y-auto rounded-xl border border-(--campaign-line) bg-(--campaign-cream) p-4 text-sm leading-6 text-(--campaign-muted)">
|
||||
{consentText}
|
||||
<div className="mt-3 text-sm leading-6 text-(--campaign-muted)">{consentText}</div>
|
||||
<div className="mt-4 rounded-xl border border-[#184e92]/20 bg-[#eef4fb] p-4 text-sm leading-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-[18px] shrink-0 text-[#184e92]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<div>
|
||||
<b className="text-[#184e92]">Aviso público sobre o índice de fotos</b>
|
||||
<p className="mt-1 text-(--campaign-muted)">
|
||||
Rostos presentes nas fotos públicas aprovadas compõem um índice sem nomes e sem
|
||||
vínculo com cadastros de pessoas. Ele serve apenas para localizar as próprias
|
||||
fotos. Você pode retirar seu rosto do índice a qualquer momento em “Minha
|
||||
presença”.
|
||||
</p>
|
||||
<details className="mt-1">
|
||||
<summary
|
||||
className={`inline-flex min-h-11 cursor-pointer items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Ler o aviso completo do índice
|
||||
</summary>
|
||||
<div className="mt-2 max-h-60 overflow-y-auto rounded-lg border border-(--campaign-line) bg-white p-3 text-(--campaign-muted)">
|
||||
{noticeText}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
|
||||
<div className="flex gap-3">
|
||||
|
|
@ -251,10 +279,11 @@ export const SelfieSearchFlow = ({
|
|||
<div className="flex gap-3">
|
||||
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-[#184e92]" />
|
||||
<p>
|
||||
<b>Você pode desistir e pedir remoção.</b>
|
||||
<b>Você mantém o controle.</b>
|
||||
<br />
|
||||
<span className="text-(--campaign-muted)">
|
||||
O resultado não mostra nomes de terceiros nem percentual de semelhança.
|
||||
Saia do índice quando quiser. O resultado não mostra nomes de terceiros nem
|
||||
percentual de semelhança.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -267,8 +296,8 @@ export const SelfieSearchFlow = ({
|
|||
className="mt-0.5 size-5 shrink-0 accent-[#184e92]"
|
||||
/>
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
|
||||
|
|
@ -456,7 +485,7 @@ export const SelfieSearchFlow = ({
|
|||
<FlowStateCard
|
||||
icon={<ImageIcon aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Não encontramos fotos suas"
|
||||
body="Esta busca funciona para quem autorizou participar do índice. Também pode não haver uma foto sua entre os registros públicos aprovados."
|
||||
body="Pode não haver uma foto sua entre os registros públicos aprovados, ou seu rosto pode já ter sido retirado do índice."
|
||||
note="Não mostramos pessoas parecidas nem tentamos adivinhar quem você é."
|
||||
action={
|
||||
<button
|
||||
|
|
@ -513,24 +542,28 @@ export const SelfieSearchFlow = ({
|
|||
id="selfie-presence-title"
|
||||
className="mt-1 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
|
||||
>
|
||||
Você decide se quer aparecer
|
||||
Você controla sua presença no índice
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
|
||||
Para sair do índice, confirme sua identidade com uma selfie. Para retirar uma foto
|
||||
pública, use o canal da equipe responsável pelo acervo.
|
||||
Seu rosto pode fazer parte do índice anônimo por aparecer no acervo aprovado. Para
|
||||
retirá-lo, use uma selfie sua. Para retirar uma foto pública, fale com a equipe do
|
||||
acervo.
|
||||
</p>
|
||||
<div className="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-5 shrink-0 rounded-full border-[5px] border-[#184e92]"
|
||||
<label className="mt-5 flex cursor-pointer gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4">
|
||||
<input
|
||||
type="radio"
|
||||
name="presence-action"
|
||||
value="leave-index"
|
||||
defaultChecked
|
||||
className="mt-0.5 size-5 shrink-0 accent-[#184e92]"
|
||||
/>
|
||||
<span>
|
||||
<b className="text-sm">Sair do índice de busca</b>
|
||||
<small className="mt-1 block text-xs leading-5 text-(--campaign-muted)">
|
||||
Seu rosto deixa de ser usado para localizar fotos por selfie.
|
||||
Apaga do índice as referências biométricas que correspondem ao seu rosto.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
{removalChannelUrl ? (
|
||||
<a
|
||||
href={removalChannelUrl}
|
||||
|
|
@ -565,9 +598,9 @@ export const SelfieSearchFlow = ({
|
|||
{step.kind === 'removed' ? (
|
||||
<FlowStateCard
|
||||
icon={<ShieldCheck aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Pronto — você saiu do índice"
|
||||
body="Seu rosto deixou de ser usado para localizar fotos por selfie e o vetor foi apagado."
|
||||
note="Se quiser voltar a participar, fale com a equipe responsável pelo acervo."
|
||||
title="Pronto — seu rosto saiu do índice"
|
||||
body="Apagamos as referências biométricas que correspondiam ao seu rosto. Ele deixa de ser encontrado pela busca por selfie."
|
||||
note="As fotos públicas continuam no álbum. Para retirar uma foto específica, use o canal da equipe responsável pelo acervo."
|
||||
action={
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
|
|
@ -576,6 +609,16 @@ export const SelfieSearchFlow = ({
|
|||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
secondary={
|
||||
removalChannelUrl ? (
|
||||
<a
|
||||
href={removalChannelUrl}
|
||||
className={`mt-3 inline-flex min-h-11 items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Pedir remoção de uma foto
|
||||
</a>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -583,7 +626,7 @@ export const SelfieSearchFlow = ({
|
|||
<FlowStateCard
|
||||
icon={<ShieldCheck aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Não encontramos sua presença no índice"
|
||||
body="Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma alteração foi feita."
|
||||
body="Não encontramos no índice referências biométricas que correspondam ao seu rosto com esta selfie. Nenhuma alteração foi feita."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -594,7 +637,7 @@ export const SelfieSearchFlow = ({
|
|||
</button>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<div className="mt-6 w-full border-t border-(--campaign-line) pt-5">
|
||||
<p className="text-sm leading-6 text-(--campaign-muted)">
|
||||
Quer retirar uma foto pública mesmo assim?
|
||||
</p>
|
||||
|
|
@ -606,10 +649,13 @@ export const SelfieSearchFlow = ({
|
|||
Pedir remoção de uma foto
|
||||
</a>
|
||||
) : null}
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`mt-4 inline-flex min-h-11 items-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -862,8 +908,8 @@ const LeaveConfirmDialog = ({
|
|||
Confirmar saída do índice?
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
|
||||
Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua presença do
|
||||
índice de busca.
|
||||
Vamos processar sua selfie neste aparelho e, se houver correspondência, apagar do índice
|
||||
as referências biométricas do seu rosto. A selfie não será enviada.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
153
src/components/fotos/SelfieSearchHomeSection.tsx
Normal file
153
src/components/fotos/SelfieSearchHomeSection.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { Check, ChevronRight, Images, ShieldCheck, Smartphone, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {
|
||||
ARCHIVE_PHOTO_ALBUM_ENTRY_PATH,
|
||||
ARCHIVE_PHOTO_ALBUM_PATH,
|
||||
} from '@/lib/archivePhotoPublicCatalog'
|
||||
|
||||
import { ARCHIVE_PHOTO_FOCUS, ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses'
|
||||
|
||||
/** `.assurance-mark` of the artifact: azure surface with the yellow radial glow. */
|
||||
const ASSURANCE_SURFACE =
|
||||
'rounded-2xl border border-[#184e92]/15 bg-[radial-gradient(circle_at_76%_18%,rgb(255_230_7/66%)_0_8%,transparent_8.5%),linear-gradient(145deg,#f9faff_0%,#eef4fb_100%)]'
|
||||
|
||||
/**
|
||||
* C243 — the selfie-search section of the public campaign homepage (artefato:
|
||||
* `busca-selfie-secao-home-ui-design.html`, cenas 01/02). It only presents and
|
||||
* links: no selfie capture, no engine, no archive photos and no counters on the
|
||||
* home — the sensitive experience starts at `/fotos/encontre`. Rendered by the
|
||||
* homepage only while the search is open (album published + flag +
|
||||
* approved photos), so a closed search leaves no dead CTA.
|
||||
*/
|
||||
const ASSURANCE_ITEMS = [
|
||||
{ icon: Smartphone, label: 'Processamento no aparelho' },
|
||||
{ icon: Images, label: 'Só o acervo público aprovado' },
|
||||
{ icon: ShieldCheck, label: 'Saída do índice a qualquer momento' },
|
||||
] as const
|
||||
|
||||
export const SelfieSearchHomeSection = () => (
|
||||
<section
|
||||
aria-labelledby="selfie-home-title"
|
||||
data-home-section="selfie-search"
|
||||
className="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-14 -right-14 size-40 rounded-full bg-(--pt-yellow)/45 sm:-top-24 sm:-right-20 sm:size-64"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -bottom-32 left-[36%] hidden size-64 rounded-full bg-(--pt-red)/8 sm:block"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto grid w-full max-w-[1160px] grid-cols-1 items-center gap-8 px-5 py-12 sm:px-10 sm:py-16 lg:grid-cols-[minmax(0,1.1fr)_minmax(360px,.9fr)] lg:gap-16">
|
||||
<div className="max-w-[620px]">
|
||||
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
Fotos da nossa caminhada
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-home-title"
|
||||
className="campaign-section-title display m-0 mt-2 max-w-[560px] border-0 p-0 text-[30px]! leading-[1.02]! font-black tracking-[-0.03em] text-balance lg:text-[38px]!"
|
||||
>
|
||||
Encontre você nas fotos
|
||||
</h2>
|
||||
<p className="campaign-section-copy mt-4 max-w-[600px] text-(--campaign-muted)">
|
||||
<span className="sm:hidden">
|
||||
Use uma selfie sua na página da busca para procurar as fotos públicas aprovadas em que
|
||||
você aparece.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
Use uma selfie sua na página da busca para procurar, entre as fotos públicas aprovadas,
|
||||
aquelas em que você aparece.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-5 hidden max-w-[610px] items-start gap-3 text-sm leading-6 sm:flex">
|
||||
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white">
|
||||
<ShieldCheck aria-hidden="true" className="size-4" strokeWidth={2} />
|
||||
</span>
|
||||
<p className="m-0 text-[#365472]">
|
||||
<b className="text-[#143c70]">A selfie não sai do seu aparelho.</b> A busca cobre o
|
||||
índice anônimo do acervo aprovado, e você pode retirar seu rosto quando quiser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 hidden flex-wrap items-center gap-x-6 gap-y-2 sm:flex">
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_ENTRY_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} shrink-0`}
|
||||
>
|
||||
Encontrar minhas fotos
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md px-2 text-sm font-extrabold text-(--pt-red) underline underline-offset-4 hover:text-[#741414] ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Ver o álbum
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={`mt-6 p-4 sm:hidden ${ASSURANCE_SURFACE}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white">
|
||||
<ShieldCheck aria-hidden="true" className="size-5" strokeWidth={2} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-extrabold text-[#143c70]">Sua selfie fica no aparelho</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[#365472]">
|
||||
A selfie não sai deste aparelho. A busca cobre o acervo aprovado, e você pode
|
||||
retirar seu rosto do índice quando quiser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:mt-0 sm:hidden">
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_ENTRY_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full`}
|
||||
>
|
||||
Encontrar minhas fotos
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`inline-flex min-h-11 w-full items-center justify-center gap-1.5 rounded-md px-2 text-sm font-extrabold text-(--pt-red) underline underline-offset-4 hover:text-[#741414] ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Ver o álbum
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`relative hidden overflow-hidden p-6 lg:block ${ASSURANCE_SURFACE}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-5 right-5 grid size-12 place-items-center rounded-full bg-white text-[#184e92] shadow-[0_8px_28px_rgb(24_78_146/12%)]"
|
||||
>
|
||||
<Sparkles aria-hidden="true" className="size-6" strokeWidth={2} />
|
||||
</span>
|
||||
<p className="max-w-[250px] font-[family-name:var(--font-exo2)] text-xl leading-tight font-black text-[#143c70]">
|
||||
Sua imagem fica com você. O controle também.
|
||||
</p>
|
||||
<ul className="mt-7 grid list-none gap-0 p-0">
|
||||
{ASSURANCE_ITEMS.map(({ icon: Icon, label }, index) => (
|
||||
<li
|
||||
key={label}
|
||||
className={`flex items-center gap-3 border-[#184e92]/12 py-4 ${
|
||||
index === ASSURANCE_ITEMS.length - 1 ? 'border-y' : 'border-t'
|
||||
}`}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-5 shrink-0 text-[#184e92]" strokeWidth={2} />
|
||||
<span className="text-sm font-bold text-[#143c70]">{label}</span>
|
||||
<Check aria-hidden="true" className="ml-auto size-4 text-[#184e92]" strokeWidth={2} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
|||
/** Shared class contracts of the public photo album (artefato C233, cenas 01–07). */
|
||||
|
||||
export const ARCHIVE_PHOTO_FOCUS =
|
||||
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'
|
||||
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]'
|
||||
|
||||
export const ARCHIVE_PHOTO_CHIP = cn(
|
||||
'inline-flex min-h-11 items-center gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 text-xs font-bold text-black',
|
||||
|
|
|
|||
|
|
@ -30,14 +30,15 @@ export const CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE =
|
|||
'Consentimento de notificações push ainda não configurado.'
|
||||
|
||||
/**
|
||||
* C234 — the public selfie search on the photo album. Two keys because the
|
||||
* two biometric uses are distinct: `FACE_SEARCH_CONSENT_KEY` is the temporary
|
||||
* use of the querying person's face to find themselves in the album (the
|
||||
* vector is discarded with the request), while `FACE_INDEX_CONSENT_KEY` is
|
||||
* the enrollment in the search index (the vector is stored until removal).
|
||||
* Both fail closed: without the Consent row with the exact key the flow is
|
||||
* closed, and editing the text invalidates every enrolled subject until
|
||||
* re-consent.
|
||||
* C242 — the public selfie search on the photo album (scope B: the anonymous
|
||||
* descriptor index of the approved archive). Two keys because the two
|
||||
* biometric documents are distinct: `FACE_SEARCH_CONSENT_KEY` is the visitor's
|
||||
* consent for the temporary use of their own face in this query (the vector is
|
||||
* discarded with the request), while `FACE_INDEX_CONSENT_KEY` is the PUBLIC
|
||||
* NOTICE of the anonymous archive index (transparency + self-service opt-out;
|
||||
* there is no per-person enrollment). Both fail closed: without the Consent row
|
||||
* with the exact key the flow is closed, and the texts are versioned in
|
||||
* `scripts/lib/faceConsentTexts.mjs` (provisioned by `pnpm seed:face-consents`).
|
||||
*/
|
||||
export const FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'
|
||||
export const FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
/**
|
||||
* C234 — pure contract of the selfie search over the public photo album.
|
||||
* C242 — pure contract of the selfie search over the public photo album
|
||||
* (scope B: the anonymous descriptor index of the approved archive, decision
|
||||
* of 2026-10-01, supersedes the A/C enrollment of C234).
|
||||
*
|
||||
* The visitor's face descriptor is computed on their own device (the selfie
|
||||
* never leaves it); the server only ever sees the 128-float vector and answers
|
||||
* with photos, never with a score. This module owns the wire vocabulary (the
|
||||
* descriptor length, the model id, the intents), the distance math, the
|
||||
* eligibility of an enrolled subject (active + current model + current consent
|
||||
* hash — fail-closed on any of the three), the result view model (which by
|
||||
* SHAPE cannot carry a similarity number or a third-party name) and the
|
||||
* deterministic stub the e2e build uses in place of the engine.
|
||||
* descriptor length, the model id, the intents), the distance math, the match
|
||||
* over photo descriptors (which by SHAPE cannot carry a similarity number),
|
||||
* the result view model (no third-party names) and the deterministic stub the
|
||||
* e2e build uses in place of the engine.
|
||||
*
|
||||
* Scope lock of the gate (PR #1370): only consented/indexed faces exist in the
|
||||
* index (A/C); the anonymous-archive index (B) is out. The vector of the
|
||||
* visitor is never persisted — it lives in the request only.
|
||||
* The anonymous photo descriptors live in `archivePhotoFace` (photo-level, no
|
||||
* identity); a match never resolves a person — only photos. The visitor's
|
||||
* vector is never persisted: it lives in the request only.
|
||||
*/
|
||||
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
|
||||
|
||||
/** face-api's faceRecognitionNet descriptor (128 floats, euclidean space). */
|
||||
export const FACE_SEARCH_DESCRIPTOR_LENGTH = 128
|
||||
|
||||
/** The engine+model the enrolled vectors belong to — a match across models is void. */
|
||||
/** The engine+model the indexed vectors belong to — a match across models is void. */
|
||||
export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'
|
||||
|
||||
/**
|
||||
|
|
@ -61,8 +62,11 @@ export const readFaceVector = (value: unknown): number[] | null => {
|
|||
return vector
|
||||
}
|
||||
|
||||
/** Euclidean distance of same-length descriptors; a length mismatch is never a match. */
|
||||
export const faceEuclideanDistance = (a: readonly number[], b: readonly number[]): number => {
|
||||
/**
|
||||
* Euclidean distance of same-length descriptors; a length mismatch is never a
|
||||
* match. Accepts typed arrays so the cached index can stay in `Float32Array`.
|
||||
*/
|
||||
export const faceEuclideanDistance = (a: ArrayLike<number>, b: ArrayLike<number>): number => {
|
||||
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY
|
||||
|
||||
let sum = 0
|
||||
|
|
@ -73,58 +77,37 @@ export const faceEuclideanDistance = (a: readonly number[], b: readonly number[]
|
|||
return Math.sqrt(sum)
|
||||
}
|
||||
|
||||
export type FaceSearchSubject = {
|
||||
id: number
|
||||
status?: string | null
|
||||
model?: string | null
|
||||
consentHash?: string | null
|
||||
vector?: unknown
|
||||
/**
|
||||
* One indexed face of the approved archive: the photo it was found in and its
|
||||
* descriptor. The row has no identity by construction — this is the scope B
|
||||
* (anonymous) contract.
|
||||
*/
|
||||
export type FaceDescriptorEntry = {
|
||||
photoId: number
|
||||
vector: ArrayLike<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* A subject only answers while it is `active`, was enrolled with the model the
|
||||
* query belongs to and its consent snapshot is still the configured text
|
||||
* (editing the Consent text invalidates every enrolled descriptor until the
|
||||
* person is re-consented — fail-closed).
|
||||
* The distinct photo ids with at least one indexed face strictly under
|
||||
* `maxDistance` from the query vector, in the order the photos appear in the
|
||||
* index. `FACE_SEARCH_MAX_DISTANCE` is the only threshold: no caller ever
|
||||
* receives a distance or a score.
|
||||
*/
|
||||
export const faceSubjectIsEligible = (
|
||||
subject: FaceSearchSubject,
|
||||
{ model, consentHash }: { model: string; consentHash: string },
|
||||
): boolean =>
|
||||
subject.status === 'active' && subject.model === model && subject.consentHash === consentHash
|
||||
export const findFaceDescriptorPhotoIds = (
|
||||
vector: ArrayLike<number>,
|
||||
descriptors: readonly FaceDescriptorEntry[],
|
||||
): number[] => {
|
||||
const matched: number[] = []
|
||||
const seen = new Set<number>()
|
||||
|
||||
/**
|
||||
* The best eligible subject strictly under `maxDistance`, or null. Subject
|
||||
* order breaks nothing: the comparison is strict, so only a closer subject
|
||||
* replaces the current best.
|
||||
*/
|
||||
export const findFaceMatch = ({
|
||||
vector,
|
||||
subjects,
|
||||
model,
|
||||
consentHash,
|
||||
}: {
|
||||
vector: readonly number[]
|
||||
subjects: readonly FaceSearchSubject[]
|
||||
model: string
|
||||
consentHash: string
|
||||
}): FaceSearchSubject | null => {
|
||||
let best: FaceSearchSubject | null = null
|
||||
let bestDistance = FACE_SEARCH_MAX_DISTANCE
|
||||
|
||||
for (const subject of subjects) {
|
||||
if (!faceSubjectIsEligible(subject, { model, consentHash })) continue
|
||||
const candidate = readFaceVector(subject.vector)
|
||||
if (!candidate) continue
|
||||
|
||||
const distance = faceEuclideanDistance(vector, candidate)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
best = subject
|
||||
}
|
||||
for (const entry of descriptors) {
|
||||
if (seen.has(entry.photoId)) continue
|
||||
if (faceEuclideanDistance(vector, entry.vector) >= FACE_SEARCH_MAX_DISTANCE) continue
|
||||
seen.add(entry.photoId)
|
||||
matched.push(entry.photoId)
|
||||
}
|
||||
|
||||
return best
|
||||
return matched
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -165,7 +148,7 @@ export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchP
|
|||
/**
|
||||
* Deterministic stand-in for the on-device engine (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`,
|
||||
* e2e builds only): the same bytes always derive the same descriptor, so the
|
||||
* spec can seed a subject whose vector equals the fixture's without ever
|
||||
* spec can seed an indexed face whose vector equals the fixture's without ever
|
||||
* loading a model. Never used outside the stub seam.
|
||||
*/
|
||||
export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => {
|
||||
|
|
|
|||
20625
src/migrations/20261001_040904_add_archive_photo_face.json
Normal file
20625
src/migrations/20261001_040904_add_archive_photo_face.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20261001_040904_add_archive_photo_face.ts
Normal file
77
src/migrations/20261001_040904_add_archive_photo_face.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "archive_photo_face" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"photo_id" integer NOT NULL,
|
||||
"model" varchar NOT NULL,
|
||||
"detected_at" timestamp(3) with time zone,
|
||||
"vector" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "face_subject" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "face_subject_rels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_face_subject_fk";
|
||||
DROP INDEX "payload_locked_documents_rels_face_subject_id_idx";
|
||||
DROP TABLE "face_subject" CASCADE;
|
||||
DROP TABLE "face_subject_rels" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "archive_photo_face_id" integer;
|
||||
ALTER TABLE "archive_photo_face" ADD CONSTRAINT "archive_photo_face_photo_id_archive_photo_id_fk" FOREIGN KEY ("photo_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "archive_photo_face_photo_idx" ON "archive_photo_face" USING btree ("photo_id");
|
||||
CREATE INDEX "archive_photo_face_model_idx" ON "archive_photo_face" USING btree ("model");
|
||||
CREATE INDEX "archive_photo_face_updated_at_idx" ON "archive_photo_face" USING btree ("updated_at");
|
||||
CREATE INDEX "archive_photo_face_created_at_idx" ON "archive_photo_face" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_archive_photo_face_fk" FOREIGN KEY ("archive_photo_face_id") REFERENCES "public"."archive_photo_face"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_archive_photo_face_id_idx" ON "payload_locked_documents_rels" USING btree ("archive_photo_face_id");
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_subject_id";
|
||||
DROP TYPE "public"."enum_face_subject_status";`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_face_subject_status" AS ENUM('active', 'removed');
|
||||
CREATE TABLE "face_subject" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"label" varchar NOT NULL,
|
||||
"consent_id" integer NOT NULL,
|
||||
"consent_hash" varchar,
|
||||
"model" varchar,
|
||||
"enrolled_at" timestamp(3) with time zone,
|
||||
"vector" jsonb,
|
||||
"status" "enum_face_subject_status" DEFAULT 'active' NOT NULL,
|
||||
"removed_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "face_subject_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"archive_photo_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "archive_photo_face" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_archive_photo_face_fk";
|
||||
DROP INDEX "payload_locked_documents_rels_archive_photo_face_id_idx";
|
||||
DROP TABLE "archive_photo_face" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_subject_id" integer;
|
||||
ALTER TABLE "face_subject" ADD CONSTRAINT "face_subject_consent_id_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "face_subject_rels" ADD CONSTRAINT "face_subject_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."face_subject"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "face_subject_rels" ADD CONSTRAINT "face_subject_rels_archive_photo_fk" FOREIGN KEY ("archive_photo_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "face_subject_consent_idx" ON "face_subject" USING btree ("consent_id");
|
||||
CREATE INDEX "face_subject_status_idx" ON "face_subject" USING btree ("status");
|
||||
CREATE INDEX "face_subject_updated_at_idx" ON "face_subject" USING btree ("updated_at");
|
||||
CREATE INDEX "face_subject_created_at_idx" ON "face_subject" USING btree ("created_at");
|
||||
CREATE INDEX "face_subject_rels_order_idx" ON "face_subject_rels" USING btree ("order");
|
||||
CREATE INDEX "face_subject_rels_parent_idx" ON "face_subject_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "face_subject_rels_path_idx" ON "face_subject_rels" USING btree ("path");
|
||||
CREATE INDEX "face_subject_rels_archive_photo_id_idx" ON "face_subject_rels" USING btree ("archive_photo_id");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_face_subject_fk" FOREIGN KEY ("face_subject_id") REFERENCES "public"."face_subject"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_face_subject_id_idx" ON "payload_locked_documents_rels" USING btree ("face_subject_id");
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "archive_photo_face_id";`)
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ import * as migration_20260926_062039_add_archive_photo from './20260926_062039_
|
|||
import * as migration_20260926_132106_add_archive_photo_catalog from './20260926_132106_add_archive_photo_catalog';
|
||||
import * as migration_20260926_163248_add_archive_photo_publication from './20260926_163248_add_archive_photo_publication';
|
||||
import * as migration_20260927_193954_add_face_subject from './20260927_193954_add_face_subject';
|
||||
import * as migration_20261001_040904_add_archive_photo_face from './20261001_040904_add_archive_photo_face';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -556,6 +557,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260927_193954_add_face_subject.up,
|
||||
down: migration_20260927_193954_add_face_subject.down,
|
||||
name: '20260927_193954_add_face_subject'
|
||||
name: '20260927_193954_add_face_subject',
|
||||
},
|
||||
{
|
||||
up: migration_20261001_040904_add_archive_photo_face.up,
|
||||
down: migration_20261001_040904_add_archive_photo_face.down,
|
||||
name: '20261001_040904_add_archive_photo_face'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export interface Config {
|
|||
contentPiece: ContentPiece;
|
||||
contentMedia: ContentMedia;
|
||||
archivePhoto: ArchivePhoto;
|
||||
faceSubject: FaceSubject;
|
||||
archivePhotoFace: ArchivePhotoFace;
|
||||
contentEvent: ContentEvent;
|
||||
recording: Recording;
|
||||
recordingMedia: RecordingMedia;
|
||||
|
|
@ -154,7 +154,7 @@ export interface Config {
|
|||
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
|
||||
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
|
||||
archivePhoto: ArchivePhotoSelect<false> | ArchivePhotoSelect<true>;
|
||||
faceSubject: FaceSubjectSelect<false> | FaceSubjectSelect<true>;
|
||||
archivePhotoFace: ArchivePhotoFaceSelect<false> | ArchivePhotoFaceSelect<true>;
|
||||
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
|
||||
recording: RecordingSelect<false> | RecordingSelect<true>;
|
||||
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
|
||||
|
|
@ -1585,33 +1585,22 @@ export interface ArchivePhoto {
|
|||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
* Índice facial anônimo do acervo público aprovado (C242). Operado pela CLI faces:index; o descriptor nunca é exposto.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "faceSubject".
|
||||
* via the `definition` "archivePhotoFace".
|
||||
*/
|
||||
export interface FaceSubject {
|
||||
export interface ArchivePhotoFace {
|
||||
id: number;
|
||||
/**
|
||||
* Como a assessoria identifica a pessoa; nunca aparece no site.
|
||||
* Foto aprovada em que o rosto foi detectado.
|
||||
*/
|
||||
label: string;
|
||||
photo: number | ArchivePhoto;
|
||||
/**
|
||||
* O Consent da adesão ao índice (chave estável busca-selfie-indice).
|
||||
* Modelo do descriptor (troca de modelo invalida a revisão e o lote reprocessa).
|
||||
*/
|
||||
consent: number | Consent;
|
||||
/**
|
||||
* Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.
|
||||
*/
|
||||
consentHash?: string | null;
|
||||
/**
|
||||
* Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).
|
||||
*/
|
||||
model?: string | null;
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
enrolledAt?: string | null;
|
||||
model: string;
|
||||
detectedAt?: string | null;
|
||||
vector?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
|
|
@ -1621,18 +1610,6 @@ export interface FaceSubject {
|
|||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.
|
||||
*/
|
||||
status: 'active' | 'removed';
|
||||
/**
|
||||
* Data do atendimento da saída do índice.
|
||||
*/
|
||||
removedAt?: string | null;
|
||||
/**
|
||||
* Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.
|
||||
*/
|
||||
matchedPhotos?: (number | ArchivePhoto)[] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -2471,8 +2448,8 @@ export interface PayloadLockedDocument {
|
|||
value: number | ArchivePhoto;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'faceSubject';
|
||||
value: number | FaceSubject;
|
||||
relationTo: 'archivePhotoFace';
|
||||
value: number | ArchivePhotoFace;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'contentEvent';
|
||||
|
|
@ -3286,18 +3263,13 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
|
|||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "faceSubject_select".
|
||||
* via the `definition` "archivePhotoFace_select".
|
||||
*/
|
||||
export interface FaceSubjectSelect<T extends boolean = true> {
|
||||
label?: T;
|
||||
consent?: T;
|
||||
consentHash?: T;
|
||||
export interface ArchivePhotoFaceSelect<T extends boolean = true> {
|
||||
photo?: T;
|
||||
model?: T;
|
||||
enrolledAt?: T;
|
||||
detectedAt?: T;
|
||||
vector?: T;
|
||||
status?: T;
|
||||
removedAt?: T;
|
||||
matchedPhotos?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
@ -4191,7 +4163,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'contentPiece'
|
||||
| 'contentMedia'
|
||||
| 'archivePhoto'
|
||||
| 'faceSubject'
|
||||
| 'archivePhotoFace'
|
||||
| 'contentEvent'
|
||||
| 'recording'
|
||||
| 'recordingMedia'
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { pt } from 'payload/i18n/pt'
|
|||
import { Activity } from './collections/Activity'
|
||||
import { AllocationDecision } from './collections/AllocationDecision'
|
||||
import { ArchivePhoto } from './collections/ArchivePhoto'
|
||||
import { ArchivePhotoFace } from './collections/ArchivePhotoFace'
|
||||
import { CalendarFeed } from './collections/CalendarFeed'
|
||||
import { CampaignDemand } from './collections/CampaignDemand'
|
||||
import { CampaignInvite } from './collections/CampaignInvite'
|
||||
|
|
@ -26,7 +27,6 @@ import { ContentPiece } from './collections/ContentPiece'
|
|||
import { ElectionCandidate } from './collections/ElectionCandidate'
|
||||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { FaceSubject } from './collections/FaceSubject'
|
||||
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
|
||||
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
|
||||
import { Jingle } from './collections/Jingle'
|
||||
|
|
@ -140,7 +140,7 @@ export default buildConfig({
|
|||
ContentPiece,
|
||||
ContentMedia,
|
||||
ArchivePhoto,
|
||||
FaceSubject,
|
||||
ArchivePhotoFace,
|
||||
ContentEvent,
|
||||
Recording,
|
||||
RecordingMedia,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,13 @@
|
|||
import 'server-only'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import {
|
||||
FACE_SEARCH_MAX_DISTANCE,
|
||||
FACE_SEARCH_MODEL,
|
||||
faceEuclideanDistance,
|
||||
readFaceVector,
|
||||
} from '@/lib/faceSearch'
|
||||
import {
|
||||
listFaceSubjects,
|
||||
type FaceSubjectSummary,
|
||||
} from '@/utilities/faceSubjects/faceSubjectReads'
|
||||
import { FACE_SEARCH_MODEL, readFaceVector } from '@/lib/faceSearch'
|
||||
import { messageOf } from '@/utilities/media/ffmpeg'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
|
|
@ -26,18 +16,19 @@ import {
|
|||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
/**
|
||||
* C234 — the batch index of the selfie search (`pnpm faces:index`). For every
|
||||
* approved photo it downloads the private original, prepares a small RGB image
|
||||
* and asks the INJECTED analyzer (the same face-api engine the browser runs)
|
||||
* for the descriptors of every face. The descriptors are TRANSIENT — they are
|
||||
* compared against the enrolled subjects and thrown away; only the
|
||||
* photo↔subject links are persisted, inside one transaction per photo. That is
|
||||
* the A/C line of the gate: there is no anonymous face index anywhere.
|
||||
* C242 — the batch owner of the anonymous face index (`pnpm faces:index`). For
|
||||
* every approved photo it downloads the private original, prepares a small RGB
|
||||
* image and asks the INJECTED analyzer (the same face-api engine the browser
|
||||
* runs) for the descriptors of every face; the descriptors are PERSISTED as
|
||||
* identity-free rows in `archivePhotoFace` (scope B, decision of 2026-10-01:
|
||||
* any visitor can find themselves). One photo's rows are replaced atomically —
|
||||
* never merged with a previous revision.
|
||||
*
|
||||
* Skipping is honest by construction: the photo's `faces.checkedKey` records
|
||||
* the model plus the enrollment revision (`enrolledAt`) of every eligible
|
||||
* subject, so a new/updated/removed subject invalidates every photo and the
|
||||
* next run recomputes — while the batch's own writes never change the key.
|
||||
* the model that produced the stored descriptors, so a model change marks every
|
||||
* photo stale and the next run recomputes. The batch's own writes never change
|
||||
* the key (`context.faceIndex` skips the catalog derivation and the marker
|
||||
* write does not read it back).
|
||||
*
|
||||
* A failure of one photo is returned, never thrown: nothing is written, the
|
||||
* next run retries it and the receipt names the stage.
|
||||
|
|
@ -46,7 +37,7 @@ import {
|
|||
/** Long edge sent to the engine; the original never leaves the archive. */
|
||||
const IMAGE_MAX_EDGE = 1024
|
||||
|
||||
export type ArchivePhotoFaceImage = {
|
||||
type ArchivePhotoFaceImage = {
|
||||
pixels: Buffer
|
||||
width: number
|
||||
height: number
|
||||
|
|
@ -57,10 +48,10 @@ export type ArchivePhotoFaceAnalyzer = (image: ArchivePhotoFaceImage) => Promise
|
|||
|
||||
/**
|
||||
* Decodes any stored image (EXIF-rotated, alpha flattened, ≤1024px raw RGB) —
|
||||
* the exact input both the batch and the enrollment CLI hand to the engine, so
|
||||
* the exact input both the batch and the browser compute descriptors over, so
|
||||
* the selfie and the archive enter the same space.
|
||||
*/
|
||||
export const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
|
||||
const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
|
||||
const prepared = await sharp(filePath)
|
||||
.rotate()
|
||||
.resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' })
|
||||
|
|
@ -80,8 +71,6 @@ export type ArchivePhotoFaceIndexItem = {
|
|||
export type ArchivePhotoFaceIndexQueue = {
|
||||
/** The revision every processed photo is stamped with. */
|
||||
indexKey: string
|
||||
/** Subjects the batch will look for (`active` + current model + vector). */
|
||||
eligibleSubjects: number
|
||||
totalApproved: number
|
||||
/** Approved photos whose `faces.checkedKey` is not the current revision. */
|
||||
stale: number
|
||||
|
|
@ -90,39 +79,17 @@ export type ArchivePhotoFaceIndexQueue = {
|
|||
}
|
||||
|
||||
export type ArchivePhotoFaceIndexResult =
|
||||
| { status: 'indexed'; matchedSubjects: number[] }
|
||||
| { status: 'indexed'; descriptorCount: number }
|
||||
| {
|
||||
status: 'failed'
|
||||
stage: 'download' | 'image' | 'analyze' | 'write'
|
||||
error: string
|
||||
}
|
||||
|
||||
const isIndexableSubject = (subject: FaceSubjectSummary): boolean =>
|
||||
subject.status === 'active' &&
|
||||
subject.model === FACE_SEARCH_MODEL &&
|
||||
readFaceVector(subject.vector) !== null
|
||||
|
||||
/**
|
||||
* The revision stamped on each processed photo: the model plus the enrollment
|
||||
* revision of every indexable subject. Deliberately ignores `updatedAt` (the
|
||||
* batch writes `matchedPhotos`, which would bump it and make every photo stale
|
||||
* forever) and ignores removed/stale subjects (they are not searched).
|
||||
*/
|
||||
const facePhotoIndexKey = (subjects: readonly FaceSubjectSummary[]): string => {
|
||||
const revisions = subjects
|
||||
.filter(isIndexableSubject)
|
||||
.map((subject) => `${subject.id}:${subject.enrolledAt ?? ''}`)
|
||||
.sort()
|
||||
|
||||
return createHash('sha256')
|
||||
.update([FACE_SEARCH_MODEL, ...revisions].join('\n'))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch queue: the approved photos with their marker, the subjects that
|
||||
* will be searched, and the revision to stamp. `refresh` ignores the marker;
|
||||
* `limit` caps only the returned work list (the counts stay true).
|
||||
* The batch queue: the approved photos with their marker and the revision to
|
||||
* stamp. `refresh` ignores the marker; `limit` caps only the returned work list
|
||||
* (the counts stay true).
|
||||
*/
|
||||
export const listArchivePhotoFaceIndexQueue = async ({
|
||||
payload,
|
||||
|
|
@ -133,8 +100,7 @@ export const listArchivePhotoFaceIndexQueue = async ({
|
|||
refresh?: boolean
|
||||
limit?: number
|
||||
}): Promise<ArchivePhotoFaceIndexQueue> => {
|
||||
const subjects = await listFaceSubjects(payload)
|
||||
const indexKey = facePhotoIndexKey(subjects)
|
||||
const indexKey = FACE_SEARCH_MODEL
|
||||
|
||||
const photos = await payload.find({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
|
|
@ -157,7 +123,6 @@ export const listArchivePhotoFaceIndexQueue = async ({
|
|||
|
||||
return {
|
||||
indexKey,
|
||||
eligibleSubjects: subjects.filter(isIndexableSubject).length,
|
||||
totalApproved: photos.docs.length,
|
||||
stale: staleRows.length,
|
||||
items: limit && limit > 0 ? staleRows.slice(0, limit) : staleRows,
|
||||
|
|
@ -165,10 +130,9 @@ export const listArchivePhotoFaceIndexQueue = async ({
|
|||
}
|
||||
|
||||
/**
|
||||
* One photo: download → prepare → analyze → transactional link update + marker.
|
||||
* The fresh subject read happens INSIDE the transaction, so an enrollment that
|
||||
* lands mid-run is respected (the photo is simply reprocessed by the changed
|
||||
* key on a later run).
|
||||
* One photo: download → prepare → analyze → transactional descriptor replace +
|
||||
* marker. The rows of the previous revision are deleted inside the same
|
||||
* transaction, so a query never sees a half-replaced photo.
|
||||
*/
|
||||
export const indexArchivePhotoFaces = async ({
|
||||
payload,
|
||||
|
|
@ -214,13 +178,13 @@ export const indexArchivePhotoFaces = async ({
|
|||
}
|
||||
|
||||
try {
|
||||
const matchedSubjects = await writeArchivePhotoFaceMatches({
|
||||
const descriptorCount = await writeArchivePhotoFaceDescriptors({
|
||||
payload,
|
||||
photoId: item.id,
|
||||
descriptors,
|
||||
indexKey,
|
||||
})
|
||||
return { status: 'indexed', matchedSubjects }
|
||||
return { status: 'indexed', descriptorCount }
|
||||
} catch (error) {
|
||||
return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') }
|
||||
}
|
||||
|
|
@ -230,11 +194,12 @@ export const indexArchivePhotoFaces = async ({
|
|||
}
|
||||
|
||||
/**
|
||||
* Replaces the links of one photo for every eligible subject and stamps the
|
||||
* marker — one transaction with a fresh read, so a concurrent enrollment or
|
||||
* takedown never meets a half-written state.
|
||||
* Replaces the descriptor rows of one photo and stamps the marker — one
|
||||
* transaction, so a concurrent takedown or search never meets a half-written
|
||||
* photo. Invalid descriptors are dropped (fail-closed) instead of poisoning the
|
||||
* index.
|
||||
*/
|
||||
const writeArchivePhotoFaceMatches = async ({
|
||||
export const writeArchivePhotoFaceDescriptors = async ({
|
||||
payload,
|
||||
photoId,
|
||||
descriptors,
|
||||
|
|
@ -242,35 +207,35 @@ const writeArchivePhotoFaceMatches = async ({
|
|||
}: {
|
||||
payload: Payload
|
||||
photoId: number
|
||||
descriptors: number[][]
|
||||
descriptors: readonly number[][]
|
||||
indexKey: string
|
||||
}): Promise<number[]> =>
|
||||
withPayloadTransaction(payload, async ({ req }) => {
|
||||
const subjects = await listFaceSubjects(payload, req)
|
||||
const matchedSubjects: number[] = []
|
||||
}): Promise<number> => {
|
||||
const vectors = descriptors
|
||||
.map((descriptor) => readFaceVector(descriptor))
|
||||
.filter((vector): vector is number[] => vector !== null)
|
||||
|
||||
for (const subject of subjects) {
|
||||
if (!isIndexableSubject(subject)) continue
|
||||
const write = async (writeReq?: { transactionID?: number | string }) => {
|
||||
await payload.delete({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { photo: { equals: photoId } },
|
||||
depth: 0,
|
||||
req: writeReq,
|
||||
// Intentional bypass: the batch CLI is a trusted operator with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const vector = readFaceVector(subject.vector)
|
||||
if (!vector) continue
|
||||
const matched = descriptors.some(
|
||||
(descriptor) => faceEuclideanDistance(descriptor, vector) < FACE_SEARCH_MAX_DISTANCE,
|
||||
)
|
||||
const linked = subject.matchedPhotoIds.includes(photoId)
|
||||
if (matched) matchedSubjects.push(subject.id)
|
||||
if (matched === linked) continue
|
||||
|
||||
const next = matched
|
||||
? [...subject.matchedPhotoIds, photoId]
|
||||
: subject.matchedPhotoIds.filter((id) => id !== photoId)
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subject.id,
|
||||
data: { matchedPhotos: next },
|
||||
for (const vector of vectors) {
|
||||
await payload.create({
|
||||
collection: 'archivePhotoFace',
|
||||
data: {
|
||||
photo: photoId,
|
||||
model: indexKey,
|
||||
detectedAt: new Date().toISOString(),
|
||||
vector,
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: the batch CLI is a trusted operator with no session.
|
||||
req: writeReq,
|
||||
// Intentional bypass: same trusted operator.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -280,11 +245,39 @@ const writeArchivePhotoFaceMatches = async ({
|
|||
id: photoId,
|
||||
data: { faces: { checkedAt: new Date().toISOString(), checkedKey: indexKey } },
|
||||
depth: 0,
|
||||
req,
|
||||
req: writeReq,
|
||||
context: { faceIndex: true },
|
||||
// Intentional bypass: the batch CLI is a trusted operator with no session.
|
||||
// Intentional bypass: same trusted operator.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
return matchedSubjects
|
||||
return withPayloadTransaction(payload, async ({ req }) => {
|
||||
await write(req)
|
||||
return vectors.length
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges every descriptor row of one photo. Called by the photo lifecycle hooks
|
||||
* when a photo stops being `approved` (unapproval/removal) and on deletion — the
|
||||
* DB-level FK cascade covers direct SQL deletes, this covers the Local API path.
|
||||
*/
|
||||
export const purgeFaceDescriptorsForPhoto = async ({
|
||||
payload,
|
||||
photoId,
|
||||
req,
|
||||
}: {
|
||||
payload: Payload
|
||||
photoId: number
|
||||
req?: PayloadRequest
|
||||
}): Promise<void> => {
|
||||
await payload.delete({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { photo: { equals: photoId } },
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: lifecycle maintenance, never an actor-bound write.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
158
src/utilities/faceIndex/faceDescriptorReads.ts
Normal file
158
src/utilities/faceIndex/faceDescriptorReads.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
FACE_SEARCH_MAX_DISTANCE,
|
||||
FACE_SEARCH_MODEL,
|
||||
faceEuclideanDistance,
|
||||
findFaceDescriptorPhotoIds,
|
||||
readFaceVector,
|
||||
type FaceDescriptorEntry,
|
||||
} from '@/lib/faceSearch'
|
||||
|
||||
/**
|
||||
* C242 — reads of the anonymous face index. The collection is admin-only and
|
||||
* machine-written, so every read is the Local API without a user (the same
|
||||
* anonymous-read bypass precedent as `archivePhotoReads`): the SELECTOR is the
|
||||
* search request itself, never an actor.
|
||||
*
|
||||
* The full set is loaded once per short window into an in-process cache
|
||||
* (`Float32Array` descriptors): the ranking is exact and linear — no pgvector
|
||||
* (C229 precedent) — and the TTL keeps an opt-out or a new index run visible
|
||||
* without a deploy. The opt-out path never uses the cache: it re-reads and
|
||||
* deletes in the same pass, because "leave-index" must take effect immediately.
|
||||
*/
|
||||
|
||||
/** How long a loaded index answers before a re-read; short on purpose. */
|
||||
const INDEX_CACHE_TTL_MS = 60_000
|
||||
|
||||
/**
|
||||
* Defensive ceiling: the flat cache costs ~600 bytes per face, so half a
|
||||
* million faces would be ~300 MB. Past the cap the search fails closed rather
|
||||
* than answering from a truncated index. The real archive (~6.5k photos) sits
|
||||
* orders of magnitude below it.
|
||||
*/
|
||||
const MAX_INDEXED_DESCRIPTORS = 300_000
|
||||
|
||||
type FaceDescriptorCache = {
|
||||
loadedAt: number
|
||||
entries: FaceDescriptorEntry[]
|
||||
}
|
||||
|
||||
let descriptorCache: FaceDescriptorCache | null = null
|
||||
|
||||
/** Test seam: a fresh read after a fixture write in the same process. */
|
||||
export const invalidateFaceDescriptorIndex = (): void => {
|
||||
descriptorCache = null
|
||||
}
|
||||
|
||||
const relationId = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') return value
|
||||
if (value && typeof value === 'object' && 'id' in value && typeof value.id === 'number') {
|
||||
return value.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const loadFaceDescriptorIndexFromDb = async (payload: Payload): Promise<FaceDescriptorEntry[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { model: { equals: FACE_SEARCH_MODEL } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { photo: true, vector: true },
|
||||
// Intentional bypass: the search endpoint answers anonymous requests and
|
||||
// the selector is the consent-gated query itself, never an actor.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
if (result.docs.length > MAX_INDEXED_DESCRIPTORS) {
|
||||
throw new Error(
|
||||
`Índice facial acima do teto defensivo (${result.docs.length} > ${MAX_INDEXED_DESCRIPTORS}).`,
|
||||
)
|
||||
}
|
||||
|
||||
const entries: FaceDescriptorEntry[] = []
|
||||
for (const doc of result.docs) {
|
||||
const photoId = relationId(doc.photo)
|
||||
const vector = readFaceVector(doc.vector)
|
||||
if (photoId === null || vector === null) continue
|
||||
entries.push({ photoId, vector: Float32Array.from(vector) })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** The current index, from the short-lived in-process cache when fresh. */
|
||||
export const loadFaceDescriptorIndex = async (payload: Payload): Promise<FaceDescriptorEntry[]> => {
|
||||
const now = Date.now()
|
||||
if (descriptorCache && now - descriptorCache.loadedAt < INDEX_CACHE_TTL_MS) {
|
||||
return descriptorCache.entries
|
||||
}
|
||||
|
||||
const entries = await loadFaceDescriptorIndexFromDb(payload)
|
||||
descriptorCache = { loadedAt: now, entries }
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct approved-photo ids the query vector matches, in index order.
|
||||
* Pure math on the loaded index; the caller intersects with the approved album
|
||||
* read (draft/removed never leave this function as a result).
|
||||
*/
|
||||
export const findMatchedFacePhotoIds = ({
|
||||
vector,
|
||||
entries,
|
||||
}: {
|
||||
vector: ArrayLike<number>
|
||||
entries: readonly FaceDescriptorEntry[]
|
||||
}): number[] => findFaceDescriptorPhotoIds(vector, entries)
|
||||
|
||||
/**
|
||||
* Opt-out ("leave-index"): the person's own descriptor is the authorization —
|
||||
* every indexed face under the threshold is deleted and the person stops being
|
||||
* found. Reads and deletes in one pass, never from the cache; returns how many
|
||||
* faces were erased (never which photos: the answer is not a score nor a list
|
||||
* of third-party places).
|
||||
*/
|
||||
export const deleteFaceDescriptorMatches = async ({
|
||||
payload,
|
||||
vector,
|
||||
}: {
|
||||
payload: Payload
|
||||
vector: ArrayLike<number>
|
||||
}): Promise<number> => {
|
||||
const result = await payload.find({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { model: { equals: FACE_SEARCH_MODEL } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { vector: true },
|
||||
// Intentional bypass: same anonymous-request rationale as the load above;
|
||||
// the deletion itself is authorized by the matched descriptor.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const matchedIds: number[] = []
|
||||
for (const doc of result.docs) {
|
||||
const stored = readFaceVector(doc.vector)
|
||||
if (!stored) continue
|
||||
if (faceEuclideanDistance(vector, stored) < FACE_SEARCH_MAX_DISTANCE) {
|
||||
matchedIds.push(doc.id)
|
||||
}
|
||||
}
|
||||
if (matchedIds.length === 0) return 0
|
||||
|
||||
await payload.delete({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { id: { in: matchedIds } },
|
||||
depth: 0,
|
||||
// Intentional bypass: the search endpoint is anonymous and the removal is
|
||||
// authorized by the matched descriptor, never by a session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
invalidateFaceDescriptorIndex()
|
||||
return matchedIds.length
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { FACE_SEARCH_MODEL, readFaceVector } from '@/lib/faceSearch'
|
||||
|
||||
/**
|
||||
* C234 — writes of the subject lifecycle: enrollment (one row per person who
|
||||
* adhered to the search index — A/C scope of the gate) and the removal that
|
||||
* serves the person's own opt-out. Enrollment is called only by
|
||||
* `pnpm faces:enroll`, after the CLI has proven the selfie has exactly one
|
||||
* detectable face and resolved the enrollment Consent by its stable key; the
|
||||
* removal is called by the search endpoint after the request's descriptor
|
||||
* matched the subject (the face IS the authentication). Neither path takes the
|
||||
* descriptor from any other source and the row is never public.
|
||||
*
|
||||
* Re-enrollment (`subjectId`) replaces the descriptor, refreshes the consent
|
||||
* snapshot and clears the derived links: the previous matches belong to the
|
||||
* previous descriptor and must never survive it. `enrolledAt` also advances —
|
||||
* it is the batch's staleness key, so the next `faces:index` recomputes.
|
||||
*/
|
||||
export type FaceSubjectEnrollmentInput = {
|
||||
payload: Payload
|
||||
label: string
|
||||
descriptor: readonly number[]
|
||||
consent: { id: number; contentHash: string }
|
||||
subjectId?: number | null
|
||||
}
|
||||
|
||||
export type FaceSubjectEnrollment = {
|
||||
id: number
|
||||
created: boolean
|
||||
}
|
||||
|
||||
export const enrollFaceSubject = async ({
|
||||
payload,
|
||||
label,
|
||||
descriptor,
|
||||
consent,
|
||||
subjectId,
|
||||
}: FaceSubjectEnrollmentInput): Promise<FaceSubjectEnrollment> => {
|
||||
const vector = readFaceVector(descriptor)
|
||||
if (!vector) throw new Error('Descriptor inválido para enrollment.')
|
||||
|
||||
const data = {
|
||||
label,
|
||||
consent: consent.id,
|
||||
consentHash: consent.contentHash,
|
||||
model: FACE_SEARCH_MODEL,
|
||||
vector,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
status: 'active' as const,
|
||||
removedAt: null,
|
||||
matchedPhotos: [] as number[],
|
||||
}
|
||||
|
||||
// Intentional bypass: the enrollment CLI is a trusted operator with no
|
||||
// session; the descriptor is validated above and never crosses the wire.
|
||||
const common = { depth: 0 as const, overrideAccess: true }
|
||||
|
||||
if (subjectId != null) {
|
||||
const updated = await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subjectId,
|
||||
data,
|
||||
...common,
|
||||
})
|
||||
return { id: updated.id, created: false }
|
||||
}
|
||||
|
||||
const created = await payload.create({ collection: 'faceSubject', data, ...common })
|
||||
return { id: created.id, created: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-out: the subject stops answering (the query checks `status`) and stops
|
||||
* holding biometrics (the vector and the derived links are erased). The row
|
||||
* itself stays as the attendance record. One row, one update — atomic by
|
||||
* itself, no transaction needed.
|
||||
*/
|
||||
export const removeFaceSubjectFromIndex = async ({
|
||||
payload,
|
||||
id,
|
||||
}: {
|
||||
payload: Payload
|
||||
id: number
|
||||
}): Promise<void> => {
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id,
|
||||
data: {
|
||||
status: 'removed',
|
||||
vector: null,
|
||||
removedAt: new Date().toISOString(),
|
||||
matchedPhotos: [],
|
||||
},
|
||||
depth: 0,
|
||||
// Intentional bypass: the search endpoint is anonymous and the removal is
|
||||
// authorized by the matched descriptor, never by a session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { FaceSearchSubject } from '@/lib/faceSearch'
|
||||
|
||||
/**
|
||||
* C234 — server reads of the enrolled subjects behind the selfie search. The
|
||||
* collection is admin-only, so every read here is the Local API without a user
|
||||
* (the documented anonymous-read bypass, same precedent as
|
||||
* `archivePhotoReads`): the SELECTOR is the search request itself, never an
|
||||
* actor. The rows are few (one per adhered person) and the search must see the
|
||||
* live state — no cache, so an opt-out stops answering immediately.
|
||||
*/
|
||||
|
||||
/** A relationship value at depth 0 is the id; tolerate the populated shape. */
|
||||
const relationIds = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const ids: number[] = []
|
||||
for (const entry of value) {
|
||||
if (typeof entry === 'number') {
|
||||
ids.push(entry)
|
||||
continue
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'id' in entry && typeof entry.id === 'number') {
|
||||
ids.push(entry.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export type FaceSubjectSummary = {
|
||||
id: number
|
||||
status: string | null
|
||||
model: string | null
|
||||
consentHash: string | null
|
||||
enrolledAt: string | null
|
||||
vector: unknown
|
||||
matchedPhotoIds: number[]
|
||||
}
|
||||
|
||||
type FaceSubjectDoc = {
|
||||
id: number
|
||||
status?: string | null
|
||||
model?: string | null
|
||||
consentHash?: string | null
|
||||
enrolledAt?: string | null
|
||||
vector?: unknown
|
||||
matchedPhotos?: unknown
|
||||
}
|
||||
|
||||
const toSummary = (doc: FaceSubjectDoc): FaceSubjectSummary => ({
|
||||
id: doc.id,
|
||||
status: doc.status ?? null,
|
||||
model: doc.model ?? null,
|
||||
consentHash: doc.consentHash ?? null,
|
||||
enrolledAt: doc.enrolledAt ?? null,
|
||||
vector: doc.vector,
|
||||
matchedPhotoIds: relationIds(doc.matchedPhotos),
|
||||
})
|
||||
|
||||
const faceSubjectSelect = {
|
||||
status: true,
|
||||
model: true,
|
||||
consentHash: true,
|
||||
enrolledAt: true,
|
||||
vector: true,
|
||||
matchedPhotos: true,
|
||||
} as const
|
||||
|
||||
/** Every subject (any status) — the CLI inventory and the batch's fresh read. */
|
||||
export const listFaceSubjects = async (
|
||||
payload: Payload,
|
||||
req?: { transactionID?: number | string },
|
||||
): Promise<FaceSubjectSummary[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'faceSubject',
|
||||
sort: ['id'],
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: faceSubjectSelect,
|
||||
// Intentional bypass: subjects are admin-only configuration and both the
|
||||
// search (anonymous request) and the CLI (no session) must read them.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
return result.docs.map((doc) => toSummary(doc as FaceSubjectDoc))
|
||||
}
|
||||
|
||||
/** Active subjects only — the candidate set of one search. */
|
||||
export const listSearchableFaceSubjects = async (
|
||||
payload: Payload,
|
||||
): Promise<FaceSearchSubject[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'faceSubject',
|
||||
where: { status: { equals: 'active' } },
|
||||
sort: ['id'],
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { status: true, model: true, consentHash: true, vector: true },
|
||||
// Intentional bypass: the search endpoint answers anonymous requests and
|
||||
// the selector is the consent-gated query itself, never an actor.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
return result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
status: doc.status ?? null,
|
||||
model: doc.model ?? null,
|
||||
consentHash: doc.consentHash ?? null,
|
||||
vector: doc.vector,
|
||||
}))
|
||||
}
|
||||
|
||||
/** The approved photos linked to one subject (the search answer). */
|
||||
export const getFaceSubjectMatchedPhotoIds = async (
|
||||
payload: Payload,
|
||||
id: number,
|
||||
): Promise<number[]> => {
|
||||
const doc = await payload
|
||||
.findByID({
|
||||
collection: 'faceSubject',
|
||||
id,
|
||||
depth: 0,
|
||||
select: { matchedPhotos: true },
|
||||
// Intentional bypass: same anonymous-read rationale as the list above.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
return relationIds((doc as FaceSubjectDoc | null)?.matchedPhotos)
|
||||
}
|
||||
|
|
@ -8,24 +8,24 @@ import {
|
|||
FACE_SEARCH_CONSENT_KEY,
|
||||
} from '../../src/lib/campaignConsentKeys.js'
|
||||
import { FACE_SEARCH_MODEL, faceStubDescriptorFromBytes } from '../../src/lib/faceSearch.js'
|
||||
import { hashConsentContent } from '../../src/utilities/consentContentHash.js'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { waitForStreamSettled } from './fixtures/campaignE2EFixtures'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
/**
|
||||
* C234 — the selfie search over real HTTP: the entry gated by the album flag,
|
||||
* the Consent fail-closed, the A/C answer (only the enrolled subject's approved
|
||||
* photos, never a third-party name), the honest empty and the opt-out by
|
||||
* matched descriptor. The engine is the deterministic stub
|
||||
* (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`): the fixture bytes derive the descriptor
|
||||
* the seeded subject carries, so no model is ever downloaded.
|
||||
* C242 — the selfie search over real HTTP: the entry gated by the album flag,
|
||||
* the Consent fail-closed, the scope B answer (anonymous descriptors of
|
||||
* approved photos only, never a third-party name), the honest empty, the
|
||||
* opt-out by matched descriptor and the homepage discovery section (C243). The
|
||||
* engine is the deterministic stub (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`): the
|
||||
* fixture bytes derive the descriptor the seeded face rows carry, so no model
|
||||
* is ever downloaded.
|
||||
*
|
||||
* Rows are seeded through the deployed REST API (admin session) so the server
|
||||
* process runs the real hooks. Serial: this spec owns its faceSubject/Consent
|
||||
* rows and flips the same `photoAlbum` global as the C233 spec (the project
|
||||
* depends on `frontendFotos`).
|
||||
* process runs the real hooks. Serial: this spec owns its archivePhotoFace/
|
||||
* Consent rows and flips the same `photoAlbum` global as the C233 spec (the
|
||||
* project depends on `frontendFotos`).
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
|
|
@ -34,7 +34,6 @@ const REMOVAL_CHANNEL = 'https://example.org/acervo/remocao'
|
|||
type Headers = Record<string, string>
|
||||
|
||||
const createdPhotoIds: number[] = []
|
||||
const createdSubjectIds: number[] = []
|
||||
const createdConsentKeys = new Set<string>()
|
||||
|
||||
const uniqueMarker = () => randomUUID().slice(0, 8)
|
||||
|
|
@ -134,38 +133,36 @@ const ensureConsent = async (
|
|||
return { id: doc.id, text: doc.text }
|
||||
}
|
||||
|
||||
const createSubject = async (
|
||||
/** One anonymous descriptor row, exactly like the batch writes it. */
|
||||
const createFaceRow = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
data: { vector: number[]; consentId: number; consentHash: string; matchedPhotos: number[] },
|
||||
data: { photo: number; vector: number[] },
|
||||
): Promise<number> => {
|
||||
const response = await request.post(`${BASE_URL}/api/faceSubject`, {
|
||||
const response = await request.post(`${BASE_URL}/api/archivePhotoFace`, {
|
||||
headers,
|
||||
data: {
|
||||
label: `Pessoa de teste ${uniqueMarker()}`,
|
||||
consent: data.consentId,
|
||||
consentHash: data.consentHash,
|
||||
photo: data.photo,
|
||||
model: FACE_SEARCH_MODEL,
|
||||
detectedAt: new Date().toISOString(),
|
||||
vector: data.vector,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
status: 'active',
|
||||
matchedPhotos: data.matchedPhotos,
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
const id = ((await response.json()) as { doc: { id: number } }).doc.id
|
||||
createdSubjectIds.push(id)
|
||||
return id
|
||||
return ((await response.json()) as { doc: { id: number } }).doc.id
|
||||
}
|
||||
|
||||
const faceSubjectById = async (
|
||||
const faceRowsOf = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
id: number,
|
||||
): Promise<{ status: string; vector: unknown }> => {
|
||||
const response = await request.get(`${BASE_URL}/api/faceSubject/${id}?depth=0`, { headers })
|
||||
photoId: number,
|
||||
): Promise<number> => {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/archivePhotoFace?limit=0&depth=0&where[photo][equals]=${photoId}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
return (await response.json()) as { status: string; vector: unknown }
|
||||
return ((await response.json()) as { totalDocs: number }).totalDocs
|
||||
}
|
||||
|
||||
/** Consent card → pick screen with the given fixture, ready for the primary action. */
|
||||
|
|
@ -183,11 +180,8 @@ const reachPickWithFixture = async (
|
|||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('Frontend busca por selfie (C234)', () => {
|
||||
test.describe('Frontend busca por selfie (C242)', () => {
|
||||
let headers: Headers
|
||||
let indexConsent: { id: number; text: unknown }
|
||||
let indexConsentHash: string
|
||||
let subjectId: number
|
||||
let linkedPhoto: number
|
||||
let draftLinkedPhoto: number
|
||||
let otherPhoto: number
|
||||
|
|
@ -213,8 +207,7 @@ test.describe('Frontend busca por selfie (C234)', () => {
|
|||
selfieB.buffer = await jpegWithFill('77')
|
||||
|
||||
await ensureConsent(request, headers, FACE_SEARCH_CONSENT_KEY)
|
||||
indexConsent = await ensureConsent(request, headers, FACE_INDEX_CONSENT_KEY)
|
||||
indexConsentHash = hashConsentContent(indexConsent.text)
|
||||
await ensureConsent(request, headers, FACE_INDEX_CONSENT_KEY)
|
||||
|
||||
await setAlbum(request, headers, {
|
||||
published: true,
|
||||
|
|
@ -230,22 +223,16 @@ test.describe('Frontend busca por selfie (C234)', () => {
|
|||
data: { publicationStatus: 'draft' },
|
||||
})
|
||||
|
||||
subjectId = await createSubject(request, headers, {
|
||||
vector: faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer)),
|
||||
consentId: indexConsent.id,
|
||||
consentHash: indexConsentHash,
|
||||
matchedPhotos: [linkedPhoto, draftLinkedPhoto],
|
||||
})
|
||||
const descriptor = faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer))
|
||||
await createFaceRow(request, headers, { photo: linkedPhoto, vector: descriptor })
|
||||
// The draft row is seeded by hand on purpose: the search must intersect
|
||||
// the index with the approved public read and never surface it.
|
||||
await createFaceRow(request, headers, { photo: draftLinkedPhoto, vector: descriptor })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const cleanupHeaders = await adminHeaders(request, BASE_URL).catch(() => null)
|
||||
if (cleanupHeaders) {
|
||||
for (const id of createdSubjectIds.splice(0)) {
|
||||
await request
|
||||
.delete(`${BASE_URL}/api/faceSubject/${id}`, { headers: cleanupHeaders })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
for (const id of createdPhotoIds.splice(0)) {
|
||||
await request
|
||||
.delete(`${BASE_URL}/api/archivePhoto/${id}`, { headers: cleanupHeaders })
|
||||
|
|
@ -295,6 +282,23 @@ test.describe('Frontend busca por selfie (C234)', () => {
|
|||
await expect(primary).toBeEnabled()
|
||||
})
|
||||
|
||||
test('shows the discovery section on the homepage while the search is open (C243)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await waitForStreamSettled(page)
|
||||
|
||||
const section = page.getByRole('region', { name: 'Encontre você nas fotos' })
|
||||
await expect(section).toBeVisible()
|
||||
await expect(
|
||||
section.getByRole('link', { name: 'Encontrar minhas fotos' }).first(),
|
||||
).toHaveAttribute('href', '/fotos/encontre')
|
||||
await expect(section.getByRole('link', { name: 'Ver o álbum' }).first()).toHaveAttribute(
|
||||
'href',
|
||||
'/fotos',
|
||||
)
|
||||
})
|
||||
|
||||
test('finds only the approved photos of the matched subject, without third-party names', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -373,11 +377,13 @@ test.describe('Frontend busca por selfie (C234)', () => {
|
|||
|
||||
await expect(page.getByRole('heading', { name: 'Confirmar saída do índice?' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Confirmar saída do índice' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Pronto — você saiu do índice' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Pronto — seu rosto saiu do índice' }),
|
||||
).toBeVisible()
|
||||
|
||||
const subject = await faceSubjectById(request, headers, subjectId)
|
||||
expect(subject.status).toBe('removed')
|
||||
expect(subject.vector).toBeNull()
|
||||
// The person's face rows are gone from the index (the search stops
|
||||
// answering); the photos themselves stay in the album.
|
||||
expect(await faceRowsOf(request, headers, linkedPhoto)).toBe(0)
|
||||
})
|
||||
|
||||
test('fails the engine honestly when the local processing breaks', async ({ page }) => {
|
||||
|
|
@ -429,6 +435,11 @@ test.describe('Frontend busca por selfie (C234)', () => {
|
|||
await waitForStreamSettled(page)
|
||||
await expect(page.getByRole('link', { name: 'Começar busca por selfie' })).toHaveCount(0)
|
||||
|
||||
// C243 — closed leaves no dead CTA on the homepage either.
|
||||
await page.goto('/')
|
||||
await waitForStreamSettled(page)
|
||||
await expect(page.getByRole('region', { name: 'Encontre você nas fotos' })).toHaveCount(0)
|
||||
|
||||
// Through the API context on purpose: the browser would log the
|
||||
// document's own 404 and trip the console-error guard (same choice as
|
||||
// the C233 kill-switch test).
|
||||
|
|
|
|||
|
|
@ -5,25 +5,21 @@ import { join } from 'node:path'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { enrollFaceSubject } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import { writeArchivePhotoFaceDescriptors } from '@/utilities/faceIndex/faceDescriptorIndex'
|
||||
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from './archivePhotoFixture'
|
||||
|
||||
/**
|
||||
* C234 — the fixtures shared by the face-search int specs (subject/index and
|
||||
* the API): the file-backed archive photo over the real Payload test database
|
||||
* and the enrolled subject. The two spec files serialize on the same lease
|
||||
* because the index revision is derived state over EVERY enrolled subject — an
|
||||
* enrollment in the sibling spec changes the key and would legitimately mark
|
||||
* photos stale mid-assertion.
|
||||
* C242 — the fixtures shared by the face-search int specs: the file-backed
|
||||
* archive photo over the real Payload test database and the indexed face rows
|
||||
* (scope B: anonymous descriptors, no subjects). The specs serialize on the
|
||||
* same lease because the in-process descriptor cache is derived state.
|
||||
*/
|
||||
export const FACE_SPECS_LEASE_KEY = 'c234-face-specs'
|
||||
export const FACE_INDEX_LEASE_KEY = 'c234-face-index-consent'
|
||||
export const FACE_SEARCH_LEASE_KEY = 'c234-face-search-consent'
|
||||
export const FACE_SPECS_LEASE_KEY = 'c240-face-specs'
|
||||
export const FACE_INDEX_LEASE_KEY = 'c240-face-index-consent'
|
||||
export const FACE_SEARCH_LEASE_KEY = 'c240-face-search-consent'
|
||||
|
||||
export const faceTestVector = (value: number): number[] =>
|
||||
Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => value)
|
||||
|
|
@ -86,27 +82,24 @@ export const createFaceTestPhoto = async ({
|
|||
return { id: created.id, flickrId }
|
||||
}
|
||||
|
||||
/** Enrolls a subject with the current index Consent resolved by stable key. */
|
||||
export const enrollFaceTestSubject = async ({
|
||||
/**
|
||||
* Seeds the descriptor rows of one photo exactly like the batch does (rows +
|
||||
* marker, one transaction). `model` lets a spec pin the old-model contract.
|
||||
*/
|
||||
export const indexFaceTestPhoto = async ({
|
||||
payload,
|
||||
label,
|
||||
vector,
|
||||
trackedSubjectIds,
|
||||
photoId,
|
||||
descriptors,
|
||||
model = FACE_SEARCH_MODEL,
|
||||
}: {
|
||||
payload: Payload
|
||||
label: string
|
||||
vector: number[]
|
||||
trackedSubjectIds: Set<number>
|
||||
}): Promise<number> => {
|
||||
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
|
||||
if (!consent) throw new Error('index consent not configured')
|
||||
|
||||
const enrolled = await enrollFaceSubject({
|
||||
photoId: number
|
||||
descriptors: number[][]
|
||||
model?: string
|
||||
}): Promise<number> =>
|
||||
writeArchivePhotoFaceDescriptors({
|
||||
payload,
|
||||
label,
|
||||
descriptor: vector,
|
||||
consent: { id: consent.id, contentHash: consent.contentHash },
|
||||
photoId,
|
||||
descriptors,
|
||||
indexKey: model,
|
||||
})
|
||||
trackedSubjectIds.add(enrolled.id)
|
||||
return enrolled.id
|
||||
}
|
||||
|
|
|
|||
247
tests/int/faceDescriptorIndex.int.spec.ts
Normal file
247
tests/int/faceDescriptorIndex.int.spec.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
indexArchivePhotoFaces,
|
||||
listArchivePhotoFaceIndexQueue,
|
||||
purgeFaceDescriptorsForPhoto,
|
||||
writeArchivePhotoFaceDescriptors,
|
||||
} from '@/utilities/faceIndex/faceDescriptorIndex'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createFaceTestPhoto,
|
||||
FACE_SPECS_LEASE_KEY,
|
||||
faceTestVector,
|
||||
} from '../helpers/faceIntFixtures'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
PHOTO_ALBUM_LEASE_KEY,
|
||||
withExclusiveTestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
// C242 — the anonymous index boundary over the real Payload `teqo_test`: the
|
||||
// batch's transactional descriptor replacement, the marker that makes the run
|
||||
// resumable and the purges that keep draft/removed photos out of the search.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const CHANNEL = 'https://jorge.solla.example.org/remocao'
|
||||
|
||||
const createPhoto = (approved: boolean) =>
|
||||
createFaceTestPhoto({
|
||||
payload,
|
||||
approved,
|
||||
prefix: 'c240',
|
||||
trackedFlickrIds: createdFlickrIds,
|
||||
tempDirs,
|
||||
})
|
||||
|
||||
const faceRowsOf = async (photoId: number): Promise<number> => {
|
||||
const result = await payload.count({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { photo: { equals: photoId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.totalDocs
|
||||
}
|
||||
|
||||
const markerOf = async (
|
||||
id: number,
|
||||
): Promise<{ checkedAt?: string | null; checkedKey?: string | null }> => {
|
||||
const doc = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return doc.faces ?? {}
|
||||
}
|
||||
|
||||
const queueItem = async (
|
||||
id: number,
|
||||
refresh = false,
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof listArchivePhotoFaceIndexQueue>>['items'][number] | undefined
|
||||
> => {
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh })
|
||||
return queue.items.find((item) => item.id === id)
|
||||
}
|
||||
|
||||
describe('anonymous face descriptor index (C242)', () => {
|
||||
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: false },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
await specsLease.release()
|
||||
})
|
||||
|
||||
it('lists only approved photos as stale and stamps the marker after indexing', async () => {
|
||||
const approved = await createPhoto(true)
|
||||
await createPhoto(false)
|
||||
|
||||
const before = await queueItem(approved.id)
|
||||
expect(before).toBeDefined()
|
||||
expect(before?.checkedKey).toBeNull()
|
||||
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === approved.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0), faceTestVector(0.1)],
|
||||
})
|
||||
expect(result).toEqual({ status: 'indexed', descriptorCount: 2 })
|
||||
expect(await faceRowsOf(approved.id)).toBe(2)
|
||||
|
||||
const marker = await markerOf(approved.id)
|
||||
expect(marker.checkedKey).toBe(FACE_SEARCH_MODEL)
|
||||
expect(typeof marker.checkedAt).toBe('string')
|
||||
|
||||
// The marker makes the next run a no-op for this photo.
|
||||
expect(await queueItem(approved.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces the rows of a photo on re-index instead of merging revisions', async () => {
|
||||
const photo = await createPhoto(true)
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const item = queue.items.find((entry) => entry.id === photo.id)!
|
||||
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0), faceTestVector(0.2), faceTestVector(0.3)],
|
||||
})
|
||||
expect(await faceRowsOf(photo.id)).toBe(3)
|
||||
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0.4)],
|
||||
})
|
||||
expect(await faceRowsOf(photo.id)).toBe(1)
|
||||
})
|
||||
|
||||
it('drops malformed descriptors (fail-closed) and still stamps the marker', async () => {
|
||||
const photo = await createPhoto(true)
|
||||
const written = await writeArchivePhotoFaceDescriptors({
|
||||
payload,
|
||||
photoId: photo.id,
|
||||
descriptors: [
|
||||
[1, 2, 3],
|
||||
[Number.NaN, ...faceTestVector(0).slice(1)],
|
||||
],
|
||||
indexKey: FACE_SEARCH_MODEL,
|
||||
})
|
||||
expect(written).toBe(0)
|
||||
expect(await faceRowsOf(photo.id)).toBe(0)
|
||||
expect((await markerOf(photo.id)).checkedKey).toBe(FACE_SEARCH_MODEL)
|
||||
expect(await queueItem(photo.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('purges the descriptor rows when the photo stops being approved', async () => {
|
||||
const photo = await createPhoto(true)
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(await faceRowsOf(photo.id)).toBe(1)
|
||||
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
data: { publicationStatus: 'removed' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(await faceRowsOf(photo.id)).toBe(0)
|
||||
expect(await queueItem(photo.id, true)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('purges the descriptor rows on deletion and on the explicit maintenance call', async () => {
|
||||
const photo = await createPhoto(true)
|
||||
await writeArchivePhotoFaceDescriptors({
|
||||
payload,
|
||||
photoId: photo.id,
|
||||
descriptors: [faceTestVector(0.5)],
|
||||
indexKey: FACE_SEARCH_MODEL,
|
||||
})
|
||||
expect(await faceRowsOf(photo.id)).toBe(1)
|
||||
|
||||
await purgeFaceDescriptorsForPhoto({ payload, photoId: photo.id })
|
||||
expect(await faceRowsOf(photo.id)).toBe(0)
|
||||
|
||||
await writeArchivePhotoFaceDescriptors({
|
||||
payload,
|
||||
photoId: photo.id,
|
||||
descriptors: [faceTestVector(0.5)],
|
||||
indexKey: FACE_SEARCH_MODEL,
|
||||
})
|
||||
await payload.delete({ collection: ARCHIVE_PHOTO_SLUG, id: photo.id, overrideAccess: true })
|
||||
const orphans = await payload.count({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { photo: { equals: photo.id } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(orphans.totalDocs).toBe(0)
|
||||
})
|
||||
|
||||
it('reports an indexed photo with zero faces honestly', async () => {
|
||||
const photo = await createPhoto(true)
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [],
|
||||
})
|
||||
expect(result).toEqual({ status: 'indexed', descriptorCount: 0 })
|
||||
expect(await faceRowsOf(photo.id)).toBe(0)
|
||||
expect((await markerOf(photo.id)).checkedKey).toBe(FACE_SEARCH_MODEL)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,21 +14,18 @@ vi.mock('next/cache', () => ({
|
|||
import { POST as postSelfie } from '@/app/(frontend)/api/fotos/selfie/route'
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import config from '@/payload.config'
|
||||
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import {
|
||||
indexArchivePhotoFaces,
|
||||
listArchivePhotoFaceIndexQueue,
|
||||
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
|
||||
import { invalidateFaceDescriptorIndex } from '@/utilities/faceIndex/faceDescriptorReads'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createFaceTestPhoto,
|
||||
enrollFaceTestSubject,
|
||||
FACE_INDEX_LEASE_KEY,
|
||||
FACE_SEARCH_LEASE_KEY,
|
||||
FACE_SPECS_LEASE_KEY,
|
||||
faceTestVector,
|
||||
indexFaceTestPhoto,
|
||||
} from '../helpers/faceIntFixtures'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
|
|
@ -37,14 +34,14 @@ import {
|
|||
withExclusiveTestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
// C234 — the anonymous selfie endpoint over the real Payload `teqo_test`:
|
||||
// the kill switch and the Consent fail-closed layers, the A/C answer (only
|
||||
// enrolled subjects, only approved photos, never a score or a third-party
|
||||
// name), the opt-out by matched descriptor and the anonymous anti-abuse.
|
||||
// C242 — the anonymous selfie endpoint over the real Payload `teqo_test`:
|
||||
// the kill switch and the two Consent fail-closed layers, the scope B answer
|
||||
// (anonymous descriptors of approved photos only, never a score or a
|
||||
// third-party name), the opt-out by matched descriptor and the anonymous
|
||||
// anti-abuse.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const createdSubjectIds = new Set<number>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
|
|
@ -60,25 +57,11 @@ const createPhoto = (approved: boolean) =>
|
|||
createFaceTestPhoto({
|
||||
payload,
|
||||
approved,
|
||||
prefix: 'c234-api',
|
||||
prefix: 'c240-api',
|
||||
trackedFlickrIds: createdFlickrIds,
|
||||
tempDirs,
|
||||
})
|
||||
|
||||
const enrollFixtureSubject = (label: string, vector: number[]) =>
|
||||
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
|
||||
|
||||
const indexPhotoForSubject = async (photoId: number, vector: number[]): Promise<void> => {
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photoId)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [vector],
|
||||
})
|
||||
if (result.status !== 'indexed') throw new Error(`index fixture failed: ${result.status}`)
|
||||
}
|
||||
|
||||
const setSelfieSearchEnabled = async (enabled: boolean): Promise<void> => {
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
|
|
@ -98,7 +81,7 @@ const post = (body: unknown, headers: Record<string, string> = {}): Promise<Resp
|
|||
}),
|
||||
)
|
||||
|
||||
describe('selfie search endpoint (C234)', () => {
|
||||
describe('selfie search endpoint (C242)', () => {
|
||||
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -123,10 +106,8 @@ describe('selfie search endpoint (C234)', () => {
|
|||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
for (const id of createdSubjectIds) {
|
||||
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
|
||||
}
|
||||
await setSelfieSearchEnabled(false)
|
||||
invalidateFaceDescriptorIndex()
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
await specsLease.release()
|
||||
})
|
||||
|
|
@ -165,43 +146,51 @@ describe('selfie search endpoint (C234)', () => {
|
|||
await setSelfieSearchEnabled(false)
|
||||
})
|
||||
|
||||
it('fails closed (503) while the query Consent is not configured', async () => {
|
||||
it('fails closed (503) while either Consent is not configured', async () => {
|
||||
await setSelfieSearchEnabled(true)
|
||||
try {
|
||||
// The C234 specs lease already serializes every writer of this key, so
|
||||
// the absence window is exclusive by construction.
|
||||
await payload.delete({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: FACE_SEARCH_CONSENT_KEY } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const response = await post({ vector: faceTestVector(0), intent: 'search' })
|
||||
expect(response.status).toBe(503)
|
||||
await expect(response.json()).resolves.toEqual({ ok: false, error: 'consent' })
|
||||
} finally {
|
||||
const withoutQuery = await post({ vector: faceTestVector(0), intent: 'search' })
|
||||
expect(withoutQuery.status).toBe(503)
|
||||
await expect(withoutQuery.json()).resolves.toEqual({ ok: false, error: 'consent' })
|
||||
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_SEARCH_CONSENT_KEY,
|
||||
leaseKey: FACE_SEARCH_LEASE_KEY,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: FACE_INDEX_CONSENT_KEY } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const withoutNotice = await post({ vector: faceTestVector(0), intent: 'search' })
|
||||
expect(withoutNotice.status).toBe(503)
|
||||
} finally {
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
leaseKey: FACE_INDEX_LEASE_KEY,
|
||||
})
|
||||
await setSelfieSearchEnabled(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns only the approved photos of the matched subject, without names or score', async () => {
|
||||
it('returns only the approved photos with a matching face, without names or score', async () => {
|
||||
const vector = faceTestVector(0)
|
||||
const subject = await enrollFixtureSubject('Pessoa encontrada', vector)
|
||||
const approved = await createPhoto(true)
|
||||
const draft = await createPhoto(false)
|
||||
await indexPhotoForSubject(approved.id, vector)
|
||||
|
||||
// A draft linked by hand must never surface: the endpoint intersects the
|
||||
// subject's links with the approved public read.
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
data: { matchedPhotos: [approved.id, draft.id] },
|
||||
await indexFaceTestPhoto({ payload, photoId: approved.id, descriptors: [vector] })
|
||||
// A draft with descriptor rows by hand must never surface: the endpoint
|
||||
// intersects the index with the approved public read.
|
||||
await payload.create({
|
||||
collection: 'archivePhotoFace',
|
||||
data: { photo: draft.id, model: FACE_SEARCH_MODEL, vector },
|
||||
overrideAccess: true,
|
||||
})
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const response = await post({ vector, intent: 'search' })
|
||||
|
|
@ -217,19 +206,18 @@ describe('selfie search endpoint (C234)', () => {
|
|||
expect(body.photos.map((photo) => photo.id)).toEqual([approved.id])
|
||||
expect(JSON.stringify(body)).not.toMatch(/"people"|"score"|"distance"|"similarity"/i)
|
||||
|
||||
// A face nobody enrolled in the index gets the honest empty answer.
|
||||
// A face nobody has in the index gets the honest empty answer.
|
||||
const stranger = await post({ vector: faceTestVector(1), intent: 'search' })
|
||||
await expect(stranger.json()).resolves.toEqual({ ok: true, found: false })
|
||||
|
||||
await setSelfieSearchEnabled(false)
|
||||
await removeFaceSubjectFromIndex({ payload, id: subject })
|
||||
})
|
||||
|
||||
it('serves the opt-out by matched descriptor and stops answering afterwards', async () => {
|
||||
const vector = faceTestVector(0.25)
|
||||
const subject = await enrollFixtureSubject('Sai pelo endpoint', vector)
|
||||
const approved = await createPhoto(true)
|
||||
await indexPhotoForSubject(approved.id, vector)
|
||||
await indexFaceTestPhoto({ payload, photoId: approved.id, descriptors: [vector] })
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const before = await post({ vector, intent: 'search' })
|
||||
|
|
@ -238,14 +226,12 @@ describe('selfie search endpoint (C234)', () => {
|
|||
const leave = await post({ vector, intent: 'leave-index' })
|
||||
await expect(leave.json()).resolves.toEqual({ ok: true, removed: true })
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
depth: 0,
|
||||
const rows = await payload.count({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { photo: { equals: approved.id } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.status).toBe('removed')
|
||||
expect(stored.vector).toBeNull()
|
||||
expect(rows.totalDocs).toBe(0)
|
||||
|
||||
const after = await post({ vector, intent: 'search' })
|
||||
await expect(after.json()).resolves.toEqual({ ok: true, found: false })
|
||||
|
|
@ -270,13 +256,14 @@ describe('selfie search endpoint (C234)', () => {
|
|||
|
||||
it('keeps the model contract: only the current model can match', async () => {
|
||||
const vector = faceTestVector(0.75)
|
||||
const subject = await enrollFixtureSubject('Modelo trocado', vector)
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
data: { model: 'outro-modelo@0' },
|
||||
overrideAccess: true,
|
||||
const approved = await createPhoto(true)
|
||||
await indexFaceTestPhoto({
|
||||
payload,
|
||||
photoId: approved.id,
|
||||
descriptors: [vector],
|
||||
model: 'outro-modelo@0',
|
||||
})
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const response = await post({ vector, intent: 'search' })
|
||||
|
|
|
|||
|
|
@ -1,249 +0,0 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import config from '@/payload.config'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import {
|
||||
indexArchivePhotoFaces,
|
||||
listArchivePhotoFaceIndexQueue,
|
||||
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
|
||||
import {
|
||||
getFaceSubjectMatchedPhotoIds,
|
||||
listSearchableFaceSubjects,
|
||||
} from '@/utilities/faceSubjects/faceSubjectReads'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createFaceTestPhoto,
|
||||
enrollFaceTestSubject,
|
||||
FACE_INDEX_LEASE_KEY,
|
||||
FACE_SPECS_LEASE_KEY,
|
||||
faceTestVector,
|
||||
} from '../helpers/faceIntFixtures'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
ensureLeasedConsent,
|
||||
PHOTO_ALBUM_LEASE_KEY,
|
||||
withExclusiveTestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
// C234 — the subject/index boundary over the real Payload `teqo_test`: the
|
||||
// enrollment write, the batch's transactional link replacement, the marker
|
||||
// that makes the run resumable, the opt-out that erases the descriptor and the
|
||||
// A/C rule that only enrolled subjects are ever searched.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const createdSubjectIds = new Set<number>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const CHANNEL = 'https://jorge.solla.example.org/remocao'
|
||||
|
||||
const createApprovedPhoto = () =>
|
||||
createFaceTestPhoto({
|
||||
payload,
|
||||
approved: true,
|
||||
prefix: 'c234',
|
||||
trackedFlickrIds: createdFlickrIds,
|
||||
tempDirs,
|
||||
})
|
||||
|
||||
const enrollFixtureSubject = (label: string, vector: number[]) =>
|
||||
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
|
||||
|
||||
const markerOf = async (
|
||||
id: number,
|
||||
): Promise<{ checkedAt?: string | null; checkedKey?: string | null }> => {
|
||||
const doc = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return doc.faces ?? {}
|
||||
}
|
||||
|
||||
describe('face subject enrollment and archive index (C234)', () => {
|
||||
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
leaseKey: FACE_INDEX_LEASE_KEY,
|
||||
})
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
for (const id of createdSubjectIds) {
|
||||
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
|
||||
}
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
await specsLease.release()
|
||||
})
|
||||
|
||||
it('enrolls a subject with the current model and consent snapshot', async () => {
|
||||
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
|
||||
const id = await enrollFixtureSubject('Pessoa de teste', faceTestVector(0))
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'faceSubject',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.status).toBe('active')
|
||||
expect(stored.model).toBe(FACE_SEARCH_MODEL)
|
||||
expect(stored.consentHash).toBe(consent?.contentHash)
|
||||
expect(stored.vector).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
|
||||
expect(stored.enrolledAt).toBeTruthy()
|
||||
|
||||
const searchable = await listSearchableFaceSubjects(payload)
|
||||
expect(searchable.map((subject) => subject.id)).toContain(id)
|
||||
})
|
||||
|
||||
it('links an approved photo to the enrolled subject and stamps the marker', async () => {
|
||||
const owner = await enrollFixtureSubject('Dona da foto', faceTestVector(0))
|
||||
const photo = await createApprovedPhoto()
|
||||
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
expect(queue.eligibleSubjects).toBeGreaterThanOrEqual(1)
|
||||
expect(queue.items.map((item) => item.id)).toContain(photo.id)
|
||||
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(result).toEqual({ status: 'indexed', matchedSubjects: expect.arrayContaining([owner]) })
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, owner)).toContain(photo.id)
|
||||
|
||||
const marker = await markerOf(photo.id)
|
||||
expect(marker.checkedKey).toBe(queue.indexKey)
|
||||
expect(marker.checkedAt).toBeTruthy()
|
||||
|
||||
// The marker now spares the photo on the next run.
|
||||
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
expect(nextQueue.items.map((item) => item.id)).not.toContain(photo.id)
|
||||
const refreshed = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
expect(refreshed.items.map((item) => item.id)).toContain(photo.id)
|
||||
})
|
||||
|
||||
it('removes a link that stopped matching and re-adds it when the face matches again', async () => {
|
||||
const subject = await enrollFixtureSubject('Rosto mutável', faceTestVector(0))
|
||||
const photo = await createApprovedPhoto()
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
|
||||
const indexed = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(indexed.status).toBe('indexed')
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
|
||||
|
||||
const removed = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(1)],
|
||||
})
|
||||
expect(removed.status).toBe('indexed')
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).not.toContain(photo.id)
|
||||
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
|
||||
})
|
||||
|
||||
it('changes the index key on a new enrollment so every photo is reprocessed', async () => {
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
await enrollFixtureSubject('Nova adesão', faceTestVector(0.5))
|
||||
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
expect(nextQueue.indexKey).not.toBe(queue.indexKey)
|
||||
})
|
||||
|
||||
it('erases the descriptor and the links on opt-out', async () => {
|
||||
const subject = await enrollFixtureSubject('Sai do índice', faceTestVector(0))
|
||||
const photo = await createApprovedPhoto()
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
|
||||
|
||||
await removeFaceSubjectFromIndex({ payload, id: subject })
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.status).toBe('removed')
|
||||
expect(stored.vector).toBeNull()
|
||||
expect(stored.removedAt).toBeTruthy()
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
|
||||
|
||||
const searchable = await listSearchableFaceSubjects(payload)
|
||||
expect(searchable.map((candidate) => candidate.id)).not.toContain(subject)
|
||||
})
|
||||
|
||||
it('fails a photo with no stored file instead of writing a link', async () => {
|
||||
const subject = await enrollFixtureSubject('Sem arquivo', faceTestVector(0))
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: { id: 999_999_999, filename: null, checkedKey: null },
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(result).toEqual({ status: 'failed', stage: 'download', error: expect.any(String) })
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
|
||||
})
|
||||
})
|
||||
77
tests/unit/archivePublishPlan.unit.spec.ts
Normal file
77
tests/unit/archivePublishPlan.unit.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ARCHIVE_PUBLISH_DEFAULT_OUT_DIR,
|
||||
archivePublishReportStamp,
|
||||
formatArchivePublishReport,
|
||||
parseArchivePublishCliArgs,
|
||||
summarizeArchivePublishResults,
|
||||
} from '../../scripts/lib/archivePublishPlan.mjs'
|
||||
|
||||
// C242 — the pure parser/summary of `pnpm archive:publish`: the dry-run default
|
||||
// and the honest per-photo failure accounting of the bulk approval.
|
||||
|
||||
describe('parseArchivePublishCliArgs', () => {
|
||||
it('defaults to the dry-run and parses the options', () => {
|
||||
expect(parseArchivePublishCliArgs([])).toEqual({
|
||||
apply: false,
|
||||
limit: null,
|
||||
out: ARCHIVE_PUBLISH_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
})
|
||||
|
||||
const apply = parseArchivePublishCliArgs(['--apply', '--limit', '20', '--out', 'x'])
|
||||
expect(apply.apply).toBe(true)
|
||||
expect(apply.limit).toBe(20)
|
||||
expect(apply.out).toBe('x')
|
||||
expect(parseArchivePublishCliArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on a bad limit, escaping out and unknown args', () => {
|
||||
expect(() => parseArchivePublishCliArgs(['--limit', '0'])).toThrow(/--limit/)
|
||||
expect(() => parseArchivePublishCliArgs(['--limit', 'x'])).toThrow(/--limit/)
|
||||
expect(() => parseArchivePublishCliArgs(['--out', '../etc'])).toThrow(/--out/)
|
||||
expect(() => parseArchivePublishCliArgs(['--apply=1'])).toThrow(/argumento desconhecido/)
|
||||
expect(() => parseArchivePublishCliArgs(['--limit'])).toThrow(/faltou valor/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeArchivePublishResults', () => {
|
||||
it('counts approvals and lists failures with the reason', () => {
|
||||
const summary = summarizeArchivePublishResults([
|
||||
{ photoId: 1, status: 'approved' },
|
||||
{ photoId: 2, status: 'approved' },
|
||||
{ photoId: 3, status: 'failed', error: 'canal ausente' },
|
||||
])
|
||||
|
||||
expect(summary.approved).toBe(2)
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.failures).toEqual([{ photoId: 3, error: 'canal ausente' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting', () => {
|
||||
it('stamps the receipt name and prints the apply summary', () => {
|
||||
expect(archivePublishReportStamp('2026-10-01T04:09:04.123Z')).toBe('2026-10-01T04-09-04-123Z')
|
||||
|
||||
const plan = formatArchivePublishReport({
|
||||
mode: 'plan',
|
||||
target: 'host/db',
|
||||
queue: { totalDrafts: 6492, items: 6492 },
|
||||
summary: { approved: 0, failed: 0, failures: [] },
|
||||
}).join('\n')
|
||||
expect(plan).toContain('modo: plan')
|
||||
expect(plan).toContain('drafts elegíveis: 6492')
|
||||
|
||||
const apply = formatArchivePublishReport({
|
||||
mode: 'apply',
|
||||
target: 'host/db',
|
||||
queue: { totalDrafts: 10, items: 3 },
|
||||
summary: { approved: 2, failed: 1, failures: [{ photoId: 7, error: 'sem arquivo' }] },
|
||||
}).join('\n')
|
||||
expect(apply).toContain('aprovadas: 2 | falharam: 1')
|
||||
expect(apply).toContain('✗ foto 7: sem arquivo')
|
||||
})
|
||||
})
|
||||
|
|
@ -64,11 +64,25 @@ vi.mock('@/utilities/content/contentPieceReads', () => ({
|
|||
getPublishedContentPieceItems: async () => contentPieces.items,
|
||||
}))
|
||||
|
||||
// C233 — the footer's "Fotos" discovery flag reads the approved archive photos
|
||||
// through the Payload DB; the unit env has no database and the real listing is
|
||||
// e2e-covered (frontendFotos.e2e.spec.ts).
|
||||
// C233/C243 — the footer's "Fotos" discovery flag and the selfie-search section
|
||||
// read the approved archive photos and the photoAlbum global through the
|
||||
// Payload DB; the unit env has no database and the real gate is e2e-covered
|
||||
// (frontendFotosSelfie.e2e.spec.ts).
|
||||
const homePhotoAlbum = vi.hoisted(() => ({
|
||||
hasPhotos: false,
|
||||
selfieSearchEnabled: false,
|
||||
published: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/archivePhotos/archivePhotoReads', () => ({
|
||||
hasPublishedArchivePhotos: async () => false,
|
||||
hasPublishedArchivePhotos: async () => homePhotoAlbum.hasPhotos,
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/globalReads', () => ({
|
||||
getCachedGlobal: () => async () => ({
|
||||
published: homePhotoAlbum.published,
|
||||
selfieSearchEnabled: homePhotoAlbum.selfieSearchEnabled,
|
||||
}),
|
||||
}))
|
||||
|
||||
// S14 — the card section renders the client studio island (next/font local
|
||||
|
|
@ -84,6 +98,9 @@ vi.mock('@/components/cards/CardsStudio', () => ({
|
|||
|
||||
afterEach(() => {
|
||||
contentPieces.items = []
|
||||
homePhotoAlbum.hasPhotos = false
|
||||
homePhotoAlbum.selfieSearchEnabled = false
|
||||
homePhotoAlbum.published = true
|
||||
cleanup()
|
||||
})
|
||||
|
||||
|
|
@ -144,4 +161,29 @@ describe('Campaign home', () => {
|
|||
expect(screen.queryByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Ver todas as peças/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('mostra a seção da busca por selfie só quando a busca está aberta (C243, fail-closed)', async () => {
|
||||
homePhotoAlbum.hasPhotos = true
|
||||
homePhotoAlbum.selfieSearchEnabled = true
|
||||
render(await HomePage())
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Encontre você nas fotos' })).toBeTruthy()
|
||||
const ctas = screen.getAllByRole('link', { name: /Encontrar minhas fotos/ })
|
||||
expect(ctas.length).toBeGreaterThan(0)
|
||||
for (const cta of ctas) expect(cta.getAttribute('href')).toBe('/fotos/encontre')
|
||||
|
||||
cleanup()
|
||||
|
||||
homePhotoAlbum.selfieSearchEnabled = false
|
||||
render(await HomePage())
|
||||
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
|
||||
|
||||
cleanup()
|
||||
|
||||
// Flag on but no approved photo is still closed: no dead CTA.
|
||||
homePhotoAlbum.selfieSearchEnabled = true
|
||||
homePhotoAlbum.hasPhotos = false
|
||||
render(await HomePage())
|
||||
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import { join } from 'node:path'
|
|||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C234 — the write guards of the two face CLIs, exercised as subprocesses:
|
||||
// C242 — the write guards of the face/publish CLIs, exercised as subprocesses:
|
||||
// every refusal must happen before any engine call or DB connection.
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const enrollScript = join(repoRoot, 'scripts', 'enroll-face-subject.mjs')
|
||||
const indexScript = join(repoRoot, 'scripts', 'index-archive-faces.mjs')
|
||||
const publishScript = join(repoRoot, 'scripts', 'publish-archive-photos.mjs')
|
||||
|
||||
const run = (scriptPath: string, args: string[], env: Record<string, string> = {}) =>
|
||||
spawnSync(process.execPath, [scriptPath, ...args], {
|
||||
|
|
@ -26,8 +26,8 @@ const run = (scriptPath: string, args: string[], env: Record<string, string> = {
|
|||
PAYLOAD_SECRET: 'test-secret',
|
||||
TEQO_ENV: '',
|
||||
ALLOW_REMOTE_DB: '',
|
||||
FACE_ENROLL_CONFIRM: '',
|
||||
FACE_INDEX_CONFIRM: '',
|
||||
ARCHIVE_PUBLISH_CONFIRM: '',
|
||||
S3_BUCKET: '',
|
||||
S3_ENDPOINT: '',
|
||||
S3_ACCESS_KEY_ID: '',
|
||||
|
|
@ -44,53 +44,7 @@ const withS3 = {
|
|||
S3_SECRET_ACCESS_KEY: 'secret',
|
||||
}
|
||||
|
||||
describe('faces:enroll write guards (C234)', () => {
|
||||
const enrollArgs = ['--label', 'Pessoa', '--selfie', '/tmp/nao-existe-234.jpg', '--apply']
|
||||
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(enrollScript, ['--help'], { DATABASE_URL: '' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('FACE_ENROLL_CONFIRM')
|
||||
expect(result.stdout).toContain('busca-selfie-indice')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag', () => {
|
||||
const result = run(enrollScript, enrollArgs)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('FACE_ENROLL_CONFIRM=1')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV once the intent flag is set', () => {
|
||||
const result = run(enrollScript, enrollArgs, { FACE_ENROLL_CONFIRM: '1' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('TEQO_ENV')
|
||||
})
|
||||
|
||||
it('refuses a target database that does not match the declared environment', () => {
|
||||
const result = run(enrollScript, enrollArgs, {
|
||||
FACE_ENROLL_CONFIRM: '1',
|
||||
TEQO_ENV: 'staging',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('≠ "teqo_staging"')
|
||||
})
|
||||
|
||||
it('passes the guards and refuses a missing selfie before the engine/DB', () => {
|
||||
const result = run(enrollScript, enrollArgs, {
|
||||
FACE_ENROLL_CONFIRM: '1',
|
||||
TEQO_ENV: 'production',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('não foi possível ler a selfie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('faces:index write guards (C234)', () => {
|
||||
describe('faces:index write guards (C242)', () => {
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(indexScript, ['--help'], { DATABASE_URL: '' })
|
||||
|
||||
|
|
@ -138,3 +92,37 @@ describe('faces:index write guards (C234)', () => {
|
|||
expect(output(result)).not.toContain('TEQO_ENV=')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe('archive:publish write guards (C242)', () => {
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(publishScript, ['--help'], { DATABASE_URL: '' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('ARCHIVE_PUBLISH_CONFIRM')
|
||||
expect(result.stdout).toContain('--limit')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag', () => {
|
||||
const result = run(publishScript, ['--apply'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('ARCHIVE_PUBLISH_CONFIRM=1')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV once the intent flag is set', () => {
|
||||
const result = run(publishScript, ['--apply'], { ARCHIVE_PUBLISH_CONFIRM: '1' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('TEQO_ENV')
|
||||
})
|
||||
|
||||
it('refuses a target database that does not match the declared environment', () => {
|
||||
const result = run(publishScript, ['--apply'], {
|
||||
ARCHIVE_PUBLISH_CONFIRM: '1',
|
||||
TEQO_ENV: 'staging',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('≠ "teqo_staging"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
60
tests/unit/faceConsentTexts.unit.spec.ts
Normal file
60
tests/unit/faceConsentTexts.unit.spec.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildConsentRichText,
|
||||
FACE_INDEX_CONSENT_PARAGRAPHS,
|
||||
FACE_INDEX_CONSENT_TEXT,
|
||||
FACE_SEARCH_CONSENT_PARAGRAPHS,
|
||||
FACE_SEARCH_CONSENT_TEXT,
|
||||
} from '../../scripts/lib/faceConsentTexts.mjs'
|
||||
|
||||
// C242 — the versioned legal texts of the selfie search: the Lexical shape the
|
||||
// Consent collection stores and the honest content the visitor reads.
|
||||
|
||||
describe('buildConsentRichText', () => {
|
||||
it('builds the Lexical root with one paragraph per text', () => {
|
||||
const rich = buildConsentRichText(['Um.', 'Dois.'])
|
||||
|
||||
expect(rich.root.type).toBe('root')
|
||||
expect(rich.root.children).toHaveLength(2)
|
||||
expect(rich.root.children[0].children[0].text).toBe('Um.')
|
||||
expect(rich.root.children[1].children[0].text).toBe('Dois.')
|
||||
})
|
||||
|
||||
it('produces an empty root for no paragraphs (fail-closed content)', () => {
|
||||
expect(buildConsentRichText([]).root.children).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
const flatten = (text: unknown): string =>
|
||||
(text as { root: { children: { children: { text: string }[] }[] } }).root.children
|
||||
.map((paragraph) => paragraph.children.map((child) => child.text).join(''))
|
||||
.join('\n')
|
||||
|
||||
describe('face consent texts', () => {
|
||||
it('has substantive paragraphs in both texts', () => {
|
||||
expect(FACE_SEARCH_CONSENT_PARAGRAPHS.length).toBeGreaterThanOrEqual(4)
|
||||
expect(FACE_INDEX_CONSENT_PARAGRAPHS.length).toBeGreaterThanOrEqual(4)
|
||||
for (const paragraph of [...FACE_SEARCH_CONSENT_PARAGRAPHS, ...FACE_INDEX_CONSENT_PARAGRAPHS]) {
|
||||
expect(paragraph.trim().length).toBeGreaterThan(40)
|
||||
}
|
||||
})
|
||||
|
||||
it('states the on-device processing and the LGPD basis in the query consent', () => {
|
||||
const text = flatten(FACE_SEARCH_CONSENT_TEXT)
|
||||
expect(text).toMatch(/aparelho/i)
|
||||
expect(text).toMatch(/não é enviada nem armazenada/i)
|
||||
expect(text).toMatch(/LGPD/)
|
||||
expect(text).toMatch(/retirar este consentimento/i)
|
||||
})
|
||||
|
||||
it('states the anonymous index and the self-service opt-out in the notice', () => {
|
||||
const text = flatten(FACE_INDEX_CONSENT_TEXT)
|
||||
expect(text).toMatch(/índice biométrico anônimo/i)
|
||||
expect(text).toMatch(/sem nome/i)
|
||||
expect(text).toMatch(/Minha presença/)
|
||||
expect(text).toMatch(/remoção de uma foto/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
FACE_ENROLL_DEFAULT_OUT_DIR,
|
||||
faceEnrollReportStamp,
|
||||
formatFaceEnrollReport,
|
||||
parseFaceEnrollCliArgs,
|
||||
} from '../../scripts/lib/faceEnrollPlan.mjs'
|
||||
|
||||
// C234 — the pure parser/report of `pnpm faces:enroll`: every refusal happens
|
||||
// before any engine call or DB connection, and the human lines never carry the
|
||||
// descriptor.
|
||||
|
||||
describe('parseFaceEnrollCliArgs', () => {
|
||||
it('defaults to the dry-run with the standard receipt dir', () => {
|
||||
const options = parseFaceEnrollCliArgs(['--label', 'Pessoa', '--selfie', '/tmp/selfie.jpg'])
|
||||
expect(options).toEqual({
|
||||
apply: false,
|
||||
label: 'Pessoa',
|
||||
selfie: '/tmp/selfie.jpg',
|
||||
subject: null,
|
||||
out: FACE_ENROLL_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses --apply, --subject, --out and --help', () => {
|
||||
const options = parseFaceEnrollCliArgs([
|
||||
'--apply',
|
||||
'--label',
|
||||
' Pessoa ',
|
||||
'--selfie',
|
||||
'/tmp/selfie.jpg',
|
||||
'--subject',
|
||||
'7',
|
||||
'--out',
|
||||
'data/outro',
|
||||
])
|
||||
expect(options.apply).toBe(true)
|
||||
expect(options.label).toBe(' Pessoa ')
|
||||
expect(options.subject).toBe(7)
|
||||
expect(options.out).toBe('data/outro')
|
||||
|
||||
expect(parseFaceEnrollCliArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on missing label/selfie, bad subject and unknown args', () => {
|
||||
expect(() => parseFaceEnrollCliArgs(['--selfie', '/tmp/a.jpg'])).toThrow(/--label/)
|
||||
expect(() => parseFaceEnrollCliArgs(['--label', 'Pessoa'])).toThrow(/--selfie/)
|
||||
expect(() => parseFaceEnrollCliArgs(['--label', ' ', '--selfie', '/tmp/a.jpg'])).toThrow(
|
||||
/--label/,
|
||||
)
|
||||
expect(() =>
|
||||
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--subject', '0']),
|
||||
).toThrow(/--subject/)
|
||||
expect(() =>
|
||||
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--nope']),
|
||||
).toThrow(/argumento desconhecido/)
|
||||
expect(() => parseFaceEnrollCliArgs(['--label'])).toThrow(/faltou valor/)
|
||||
expect(() =>
|
||||
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--out', '../etc']),
|
||||
).toThrow(/--out/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('faceEnrollReportStamp', () => {
|
||||
it('is filesystem-safe', () => {
|
||||
expect(faceEnrollReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFaceEnrollReport', () => {
|
||||
const base = {
|
||||
runAt: '2026-09-27T16:42:01.123Z',
|
||||
mode: 'apply',
|
||||
target: '127.0.0.1/teqo_wt234',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
label: 'Pessoa',
|
||||
subjectId: 3,
|
||||
subjectAction: 'created' as const,
|
||||
faceCount: 1,
|
||||
allowed: true,
|
||||
reason: null,
|
||||
consentKey: 'busca-selfie-indice',
|
||||
consentHash: 'abc',
|
||||
durationMs: 10,
|
||||
}
|
||||
|
||||
it('reports the enrollment without ever mentioning a descriptor', () => {
|
||||
const lines = formatFaceEnrollReport(base)
|
||||
const text = lines.join('\n')
|
||||
|
||||
expect(text).toContain('criada: #3 (Pessoa)')
|
||||
expect(text).toContain('busca-selfie-indice')
|
||||
expect(text).toContain('pnpm faces:index --apply')
|
||||
expect(text).not.toMatch(/descriptor|vetor|vector/i)
|
||||
})
|
||||
|
||||
it('shows the dry-run as a plan, never as a write', () => {
|
||||
const lines = formatFaceEnrollReport({
|
||||
...base,
|
||||
mode: 'plan',
|
||||
subjectAction: null,
|
||||
subjectId: null,
|
||||
})
|
||||
const text = lines.join('\n')
|
||||
|
||||
expect(text).toContain('plano: Pessoa (nada gravado')
|
||||
expect(text).not.toContain('criada')
|
||||
})
|
||||
|
||||
it('shows the refusal reason and no subject line', () => {
|
||||
const lines = formatFaceEnrollReport({
|
||||
...base,
|
||||
allowed: false,
|
||||
subjectId: null,
|
||||
subjectAction: null,
|
||||
faceCount: 2,
|
||||
reason: 'mais de um rosto na selfie',
|
||||
})
|
||||
const text = lines.join('\n')
|
||||
|
||||
expect(text).toContain('RECUSADO: mais de um rosto na selfie')
|
||||
expect(text).not.toContain('criada')
|
||||
})
|
||||
})
|
||||
|
|
@ -12,8 +12,8 @@ import {
|
|||
summarizeFaceIndexResults,
|
||||
} from '../../scripts/lib/faceIndexPlan.mjs'
|
||||
|
||||
// C234 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
|
||||
// accounting and the operator-facing lines.
|
||||
// C242 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
|
||||
// accounting (scope B: descriptors, not subjects) and the operator-facing lines.
|
||||
|
||||
describe('parseFaceIndexCliArgs', () => {
|
||||
it('defaults to the dry-run and parses the modes', () => {
|
||||
|
|
@ -47,82 +47,43 @@ describe('parseFaceIndexCliArgs', () => {
|
|||
})
|
||||
|
||||
describe('summarizeFaceIndexResults', () => {
|
||||
it('counts indexed/linked/failed and aggregates links per subject', () => {
|
||||
it('counts indexed photos, stored descriptors and failures with the stage', () => {
|
||||
const summary = summarizeFaceIndexResults([
|
||||
{ photoId: 1, status: 'indexed', matchedSubjects: [3, 4] },
|
||||
{ photoId: 2, status: 'indexed', matchedSubjects: [3] },
|
||||
{ photoId: 3, status: 'indexed', matchedSubjects: [] },
|
||||
{ photoId: 1, status: 'indexed', descriptorCount: 3 },
|
||||
{ photoId: 2, status: 'indexed', descriptorCount: 0 },
|
||||
{ photoId: 3, status: 'indexed', descriptorCount: 1 },
|
||||
{ photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' },
|
||||
])
|
||||
|
||||
expect(summary.indexed).toBe(3)
|
||||
expect(summary.descriptors).toBe(4)
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.linkedPhotos).toBe(2)
|
||||
expect(summary.linksBySubject).toEqual([
|
||||
{ subjectId: 3, photos: 2 },
|
||||
{ subjectId: 4, photos: 1 },
|
||||
])
|
||||
expect(summary.failures).toEqual([{ photoId: 4, stage: 'download', error: 'sem arquivo' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeFaceIndexInventory', () => {
|
||||
const queue = { indexKey: 'k', eligibleSubjects: 2, totalApproved: 10, stale: 4 }
|
||||
const subjects = [
|
||||
{
|
||||
id: 1,
|
||||
status: 'active',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
consentHash: 'hash-atual',
|
||||
vector: [0],
|
||||
matchedPhotoIds: [1, 2],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'active',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
consentHash: 'hash-antigo',
|
||||
vector: [0],
|
||||
matchedPhotoIds: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
status: 'removed',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
consentHash: 'hash-atual',
|
||||
vector: null,
|
||||
matchedPhotoIds: [],
|
||||
},
|
||||
]
|
||||
|
||||
it('derives the honest gap, the consent staleness and the links breakdown', () => {
|
||||
it('derives the honest gap and the stored descriptor count', () => {
|
||||
const inventory = summarizeFaceIndexInventory({
|
||||
queue,
|
||||
subjects,
|
||||
currentConsentHash: 'hash-atual',
|
||||
queue: { indexKey: 'face-api@1.7.15/faceRecognitionNet', totalApproved: 10, stale: 4 },
|
||||
descriptors: 23,
|
||||
})
|
||||
|
||||
expect(inventory).toEqual({
|
||||
indexKey: 'k',
|
||||
indexKey: 'face-api@1.7.15/faceRecognitionNet',
|
||||
totalApproved: 10,
|
||||
indexed: 6,
|
||||
stale: 4,
|
||||
eligibleSubjects: 2,
|
||||
activeSubjects: 2,
|
||||
removedSubjects: 1,
|
||||
staleConsentSubjects: 1,
|
||||
linkedPhotos: 2,
|
||||
linksBySubject: [{ subjectId: 1, photos: 2 }],
|
||||
descriptors: 23,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a missing current consent as stale for everyone', () => {
|
||||
it('never reports a negative indexed count', () => {
|
||||
const inventory = summarizeFaceIndexInventory({
|
||||
queue,
|
||||
subjects,
|
||||
currentConsentHash: null,
|
||||
queue: { indexKey: 'k', totalApproved: 2, stale: 5 },
|
||||
descriptors: 0,
|
||||
})
|
||||
expect(inventory.staleConsentSubjects).toBe(2)
|
||||
expect(inventory.indexed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -136,20 +97,14 @@ describe('formatting', () => {
|
|||
totalApproved: 10,
|
||||
indexed: 6,
|
||||
stale: 4,
|
||||
eligibleSubjects: 2,
|
||||
activeSubjects: 2,
|
||||
removedSubjects: 1,
|
||||
staleConsentSubjects: 1,
|
||||
linkedPhotos: 2,
|
||||
linksBySubject: [{ subjectId: 1, photos: 2 }],
|
||||
descriptors: 23,
|
||||
},
|
||||
'face-api@1.7.15/faceRecognitionNet',
|
||||
)
|
||||
const text = lines.join('\n')
|
||||
expect(text).toContain('fotos aprovadas: 10')
|
||||
expect(text).toContain('desatualizadas (a processar): 4')
|
||||
expect(text).toContain('consentimento vencido')
|
||||
expect(text).toContain('sujeito #1: 2 foto(s)')
|
||||
expect(text).toContain('rostos no índice (modelo atual): 23')
|
||||
})
|
||||
|
||||
it('prints the apply summary with failures', () => {
|
||||
|
|
@ -157,20 +112,18 @@ describe('formatting', () => {
|
|||
mode: 'apply',
|
||||
target: 'host/db',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
queue: { items: 3, totalApproved: 10, eligibleSubjects: 1 },
|
||||
queue: { items: 3, totalApproved: 10 },
|
||||
summary: {
|
||||
indexed: 2,
|
||||
failed: 1,
|
||||
linkedPhotos: 1,
|
||||
linksBySubject: [{ subjectId: 3, photos: 1 }],
|
||||
descriptors: 5,
|
||||
failures: [{ photoId: 4, stage: 'analyze', error: 'engine fora' }],
|
||||
},
|
||||
}
|
||||
const text = formatFaceIndexReport(report).join('\n')
|
||||
|
||||
expect(text).toContain('modo: apply')
|
||||
expect(text).toContain('processadas: 2 | vinculadas a alguém: 1 | falharam: 1')
|
||||
expect(text).toContain('sujeito #3: 1 foto(s)')
|
||||
expect(text).toContain('processadas: 2 | rostos indexados: 5 | falharam: 1')
|
||||
expect(text).toContain('✗ foto 4 [analyze]: engine fora')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,39 +6,20 @@ import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
|
|||
import {
|
||||
FACE_SEARCH_DESCRIPTOR_LENGTH,
|
||||
FACE_SEARCH_MAX_DISTANCE,
|
||||
FACE_SEARCH_MODEL,
|
||||
faceEuclideanDistance,
|
||||
faceStubDescriptorFromBytes,
|
||||
faceSubjectIsEligible,
|
||||
findFaceMatch,
|
||||
findFaceDescriptorPhotoIds,
|
||||
readFaceVector,
|
||||
toFaceSearchPhotoView,
|
||||
} from '@/lib/faceSearch'
|
||||
|
||||
// C234 — the pure contract of the selfie search: vector validation, the
|
||||
// euclidean math, the eligibility of an enrolled subject and the result view
|
||||
// model that by shape cannot carry a score or a third-party name.
|
||||
// C242 — the pure contract of the selfie search: vector validation, the
|
||||
// euclidean math, the anonymous descriptor match (which by shape cannot carry a
|
||||
// score or a third-party name) and the result view model.
|
||||
|
||||
const vectorOf = (value: number, length = FACE_SEARCH_DESCRIPTOR_LENGTH): number[] =>
|
||||
Array.from({ length }, () => value)
|
||||
|
||||
const subjectOf = (
|
||||
overrides: Partial<{
|
||||
id: number
|
||||
status: string
|
||||
model: string
|
||||
consentHash: string | null
|
||||
vector: unknown
|
||||
}> = {},
|
||||
) => ({
|
||||
id: 1,
|
||||
status: 'active',
|
||||
model: FACE_SEARCH_MODEL,
|
||||
consentHash: 'hash-atual',
|
||||
vector: vectorOf(0),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('readFaceVector', () => {
|
||||
it('accepts exactly the descriptor length of finite numbers', () => {
|
||||
expect(readFaceVector(vectorOf(0.25))).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
|
||||
|
|
@ -67,60 +48,41 @@ describe('faceEuclideanDistance', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('faceSubjectIsEligible', () => {
|
||||
const current = { model: FACE_SEARCH_MODEL, consentHash: 'hash-atual' }
|
||||
|
||||
it('requires active status, the current model and the current consent hash', () => {
|
||||
expect(faceSubjectIsEligible(subjectOf(), current)).toBe(true)
|
||||
expect(faceSubjectIsEligible(subjectOf({ status: 'removed' }), current)).toBe(false)
|
||||
expect(faceSubjectIsEligible(subjectOf({ model: 'outro-modelo' }), current)).toBe(false)
|
||||
expect(faceSubjectIsEligible(subjectOf({ consentHash: 'hash-antigo' }), current)).toBe(false)
|
||||
expect(faceSubjectIsEligible(subjectOf({ consentHash: null }), current)).toBe(false)
|
||||
describe('findFaceDescriptorPhotoIds', () => {
|
||||
const near = (photoId: number, delta = 0.1) => ({
|
||||
photoId,
|
||||
vector: [...vectorOf(0).slice(1), delta],
|
||||
})
|
||||
})
|
||||
|
||||
describe('findFaceMatch', () => {
|
||||
const current = { model: FACE_SEARCH_MODEL, consentHash: 'hash-atual' }
|
||||
|
||||
it('returns the closest eligible subject strictly under the threshold', () => {
|
||||
const far = subjectOf({ id: 1, vector: [...vectorOf(0).slice(1), 1] })
|
||||
const near = subjectOf({ id: 2, vector: [...vectorOf(0).slice(1), 0.1] })
|
||||
const match = findFaceMatch({
|
||||
vector: vectorOf(0),
|
||||
subjects: [far, near],
|
||||
...current,
|
||||
})
|
||||
expect(match?.id).toBe(2)
|
||||
const far = (photoId: number, delta = FACE_SEARCH_MAX_DISTANCE + 1) => ({
|
||||
photoId,
|
||||
vector: [...vectorOf(0).slice(1), delta],
|
||||
})
|
||||
|
||||
it('ignores ineligible subjects and subjects with a malformed vector', () => {
|
||||
const removed = subjectOf({ id: 1, status: 'removed', vector: vectorOf(0) })
|
||||
const staleModel = subjectOf({ id: 2, model: 'outro', vector: vectorOf(0) })
|
||||
const staleConsent = subjectOf({ id: 3, consentHash: 'antigo', vector: vectorOf(0) })
|
||||
const broken = subjectOf({ id: 4, vector: [1, 2, 3] })
|
||||
const eligible = subjectOf({ id: 5, vector: vectorOf(0) })
|
||||
|
||||
const match = findFaceMatch({
|
||||
vector: vectorOf(0),
|
||||
subjects: [removed, staleModel, staleConsent, broken, eligible],
|
||||
...current,
|
||||
})
|
||||
expect(match?.id).toBe(5)
|
||||
it('returns the distinct photo ids with a face under the threshold', () => {
|
||||
const ids = findFaceDescriptorPhotoIds(vectorOf(0), [
|
||||
near(7),
|
||||
near(7, 0.2),
|
||||
near(9, 0.3),
|
||||
far(11),
|
||||
])
|
||||
expect(ids).toEqual([7, 9])
|
||||
})
|
||||
|
||||
it('never matches at or beyond the threshold and answers null when nothing is close', () => {
|
||||
const atThreshold = subjectOf({
|
||||
id: 1,
|
||||
vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE],
|
||||
})
|
||||
expect(findFaceMatch({ vector: vectorOf(0), subjects: [atThreshold], ...current })).toBeNull()
|
||||
it('never matches at or beyond the threshold and answers empty when nothing is close', () => {
|
||||
expect(
|
||||
findFaceDescriptorPhotoIds(vectorOf(0), [
|
||||
{ photoId: 1, vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE] },
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(findFaceDescriptorPhotoIds(vectorOf(0), [far(2)])).toEqual([])
|
||||
expect(findFaceDescriptorPhotoIds(vectorOf(0), [])).toEqual([])
|
||||
})
|
||||
|
||||
const far = subjectOf({
|
||||
id: 2,
|
||||
vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE + 1],
|
||||
})
|
||||
expect(findFaceMatch({ vector: vectorOf(0), subjects: [far], ...current })).toBeNull()
|
||||
expect(findFaceMatch({ vector: vectorOf(0), subjects: [], ...current })).toBeNull()
|
||||
it('accepts Float32Array descriptors (the in-process cache shape)', () => {
|
||||
const ids = findFaceDescriptorPhotoIds(vectorOf(0), [
|
||||
{ photoId: 3, vector: Float32Array.from(near(3).vector) },
|
||||
])
|
||||
expect(ids).toEqual([3])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue