teqo-backup/src/collections/ArchivePhoto.ts
Francisco Solla 81f87c78d1 feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e
  sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default
  false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não
  seedados — a superfície nasce fechada até o aval jurídico/DPIA).
- Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são
  transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos
  matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt).
- Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin
  (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node.
  CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232.
- Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233;
  rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon.
- UI portada classe-a-classe do design estendido (paridade certificada, tier primário);
  e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine
  real; runbook §C234, contrato em AGENTS-public.md e changelog.
- Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
2026-09-27 19:03:30 -03:00

549 lines
16 KiB
TypeScript

import type {
CollectionAfterChangeHook,
CollectionAfterDeleteHook,
CollectionBeforeValidateHook,
CollectionConfig,
Field,
} from 'payload'
import { ValidationError } from 'payload'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import {
ARCHIVE_PHOTO_CAPTION_MAX_LENGTH,
ARCHIVE_PHOTO_CATALOG_SOURCES,
ARCHIVE_PHOTO_CURATED_FIELDS,
ARCHIVE_PHOTO_DESCRIPTION_MAX_LENGTH,
ARCHIVE_PHOTO_PUBLICATION_STATUSES,
ARCHIVE_PHOTO_SCENES,
ARCHIVE_PHOTO_VISIBLE_TEXT_MAX_LENGTH,
archivePhotoCatalogSourceLabels,
archivePhotoCuratedFieldLabels,
archivePhotoPublicationStatusLabels,
archivePhotoSearchText,
archivePhotoTakenOn,
changedArchivePhotoCuratedFields,
isArchivePhotoRemovalChannelUrl,
} from '@/lib/archivePhotoCatalog'
import { SPEECH_TOPICS } from '@/lib/speechFacets'
import { canReadArchivePhoto, payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateArchivePhotosListing } from '@/utilities/documents'
/**
* C231 — private upload collection of the Flickr photo archive: the ORIGINAL
* file plus the metadata the Flickr listing brought (date, albums,
* title/description/tags, geotag, EXIF). Like `internetSpeechMedia` and
* `recordingMedia`, deliberately NOT `media`: that collection is anonymous-read
* by contract (public pages), while the raw archive may only be read by the
* communication roles. It is ingested by `pnpm flickr:import` and nothing here
* publishes — C232 curates, C233/C234 publish.
*
* `alt` is required by the upload contract and derived by the ingestion
* (`archivePhotoAlt`); the remaining Flickr fields document provenance and feed
* the catalogue. The stored name is deterministic (`flickr-<id>.<ext>`), so a
* re-run converges by overwriting the same object key.
*
* C232 adds the curated pre-cataloguing on these same rows: the `catalog` group
* (proposal + honest `source`/`catalogedAt` state), `curatedFields` (what the
* assessoria edited — the pipeline never overwrites it), `takenOn` (date facet
* derived from the Flickr wall clock) and `searchText` (the normalized haystack
* the admin list search matches).
*/
const catalogGroup: Field = {
name: 'catalog',
type: 'group',
label: 'Pré-catalogação',
admin: {
description:
'Proposta automática (IA + metadados). A assessoria revisa na ficha: o que ela editar entra em "Campos curados" e nunca é sobrescrito.',
},
fields: [
{
name: 'caption',
type: 'text',
label: 'Legenda',
maxLength: ARCHIVE_PHOTO_CAPTION_MAX_LENGTH,
},
{
name: 'description',
type: 'textarea',
label: 'Descrição',
maxLength: ARCHIVE_PHOTO_DESCRIPTION_MAX_LENGTH,
admin: {
description: 'Descrição semântica do que a foto mostra (não substitui a do Flickr).',
},
},
{
name: 'scene',
type: 'select',
label: 'Atividade/cena',
index: true,
options: ARCHIVE_PHOTO_SCENES.map((scene) => ({ value: scene.value, label: scene.label })),
},
{
name: 'visibleText',
type: 'textarea',
label: 'Texto visível',
maxLength: ARCHIVE_PHOTO_VISIBLE_TEXT_MAX_LENGTH,
admin: {
description: 'Texto legível na imagem (faixas, placas, banners).',
},
},
{
name: 'hasPeople',
type: 'checkbox',
label: 'Pessoas na cena',
admin: {
description: 'Presença de pessoas na foto — sem identificação de rosto (biometria é C234).',
},
},
{
name: 'themes',
type: 'select',
label: 'Temas',
hasMany: true,
index: true,
options: SPEECH_TOPICS.map((topic) => ({ value: topic.value, label: topic.label })),
},
{
name: 'people',
type: 'text',
hasMany: true,
label: 'Pessoas públicas',
admin: {
description:
'Somente nomes do catálogo curado, mencionados no texto da foto; nunca inferidos de rosto.',
},
},
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
index: true,
admin: {
description: 'Quando o texto da foto aponta um município sem ambiguidade.',
},
},
{
name: 'source',
type: 'select',
label: 'Origem',
options: ARCHIVE_PHOTO_CATALOG_SOURCES.map((source) => ({
value: source,
label: archivePhotoCatalogSourceLabels[source],
})),
admin: {
readOnly: true,
description: 'IA = o modelo contribuiu; Metadados = só o texto derivou; Nada a propor.',
},
},
{
name: 'catalogedAt',
type: 'date',
label: 'Catalogada em',
index: true,
admin: {
readOnly: true,
description: 'Chave de idempotência: foto com data preenchida não é reprocessada.',
},
},
],
}
/**
* C232 — the catalogue index the admin list uses: `takenOn` (date facet derived
* from the Flickr wall clock) and `searchText` (normalized haystack). When a
* request carries a user (an admin edit), the fields that actually changed are
* added to `curatedFields` — the cataloguing/hook never marks on system writes
* (`context.archivePhotoCatalog`, or no user at all).
*/
const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({
data,
originalDoc,
req,
}) => {
if (!data) return data
// C234 — the face-index batch only ever writes the hidden `faces` group
// (the marker), so it skips the search-text/município derivation entirely;
// the curation marking below is already gated on `req.user` and never runs
// for a session-less CLI.
if (req.context?.faceIndex) return data
if (req.user && !req.context?.archivePhotoCatalog) {
const changed = changedArchivePhotoCuratedFields({ data, originalDoc })
if (changed.length > 0) {
const curated = new Set<string>(originalDoc?.curatedFields ?? [])
for (const field of changed) curated.add(field)
data.curatedFields = [...curated]
}
}
const takenAt = data.takenAt ?? originalDoc?.takenAt ?? null
data.takenOn = archivePhotoTakenOn(takenAt)
const catalog = { ...(originalDoc?.catalog ?? {}), ...(data.catalog ?? {}) }
const rawMunicipality = catalog.municipality
const municipalityId =
typeof rawMunicipality === 'number'
? rawMunicipality
: rawMunicipality && typeof rawMunicipality === 'object'
? rawMunicipality.id
: null
const municipality =
municipalityId != null
? await req.payload
.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { name: true, slug: true },
// Intentional admin bypass: the município catalog is read-only geography.
overrideAccess: true,
req,
})
.catch(() => null)
: null
// C233 — the public album reads `depth 0` on this row (the `municipality`
// collection is campaign-only), so the label/slug the facet needs are
// snapshotted here, where the geography read already happens.
data.municipalityName = municipality?.name ?? null
data.municipalitySlug = municipality?.slug ?? null
data.searchText = archivePhotoSearchText({
alt: data.alt ?? originalDoc?.alt,
title: data.title ?? originalDoc?.title,
description: data.description ?? originalDoc?.description,
catalogDescription: catalog.description,
caption: catalog.caption,
scene: catalog.scene,
visibleText: catalog.visibleText,
themes: catalog.themes,
people: catalog.people,
municipalityName: municipality?.name ?? null,
albumTitles: (data.albums ?? originalDoc?.albums ?? []).map(
(album: { title?: string | null }) => album?.title,
),
tagNames: (data.tags ?? originalDoc?.tags ?? []).map(
(tag: { name?: string | null }) => tag?.name,
),
})
return data
}
/**
* C233 — fail-closed: a photo only becomes public with a visible removal
* channel ("é você nesta foto? peça a remoção"). The channel is product data
* configured in the album global; this guard never invents one — it refuses
* the approval until a valid channel exists.
*/
const requireRemovalChannelForApproval: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data) return data
// Only the transition INTO approval is guarded: a later edit of an already
// approved photo (the cataloguing pipeline included) never re-checks the
// channel, and the album global keeps the open album from losing it.
if (data.publicationStatus !== 'approved') return data
// Intentional admin bypass: the channel is public read-only product data and
// the guard must not depend on the role of whoever is approving.
const album = await req.payload
.findGlobal({ slug: 'photoAlbum', depth: 0, overrideAccess: true, req })
.catch(() => null)
if (!isArchivePhotoRemovalChannelUrl(album?.removalChannelUrl)) {
throw new ValidationError({
errors: [
{
path: 'publicationStatus',
message:
'Configure o canal de remoção (Configurações → Álbum de fotos) antes de aprovar uma foto.',
},
],
})
}
return data
}
/**
* C233 — the seam with the public album: every write of a photo (the ficha,
* the cataloguing pipeline, the takedown) busts the listing tag `/fotos`
* caches under. A collection hook and not a per-caller line, so a new write
* path cannot forget it — an `approved→removed` edit pulls the photo AND its
* media (the cached by-id read) down without a deploy.
*/
const revalidateArchivePhotosListingAfterChange: CollectionAfterChangeHook = ({ doc }) => {
revalidateArchivePhotosListing()
return doc
}
const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({ doc }) => {
revalidateArchivePhotosListing()
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
* model plus every eligible subject, so a new enrollment (or a model change)
* changes the key and the photo is reprocessed. Written only by the CLI; no
* admin surface (hidden) and never edited by hand.
*/
const facesGroup: Field = {
name: 'faces',
type: 'group',
label: 'Índice facial',
admin: {
hidden: true,
description: 'Estado do lote da busca por selfie (C234). Nunca editado à mão.',
},
fields: [
{
name: 'checkedAt',
type: 'date',
label: 'Verificada em',
admin: {
readOnly: true,
},
},
{
name: 'checkedKey',
type: 'text',
label: 'Chave do índice',
admin: {
readOnly: true,
},
},
],
}
export const ArchivePhoto: CollectionConfig = {
slug: ARCHIVE_PHOTO_SLUG,
labels: {
singular: 'Foto do acervo',
plural: 'Fotos do acervo',
},
admin: {
group: 'Comunicação',
description:
'Originais e metadados do acervo de fotos do Flickr (conta depjorgesolla). Só abrem com login da campanha.',
useAsTitle: 'alt',
defaultColumns: [
'alt',
'publicationStatus',
'takenOn',
'catalog.scene',
'catalog.municipality',
'catalog.source',
],
listSearchableFields: ['searchText'],
},
access: {
create: payloadAdminOnly,
read: canReadArchivePhoto,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
beforeValidate: [deriveArchivePhotoCatalogIndex, requireRemovalChannelForApproval],
afterChange: [revalidateArchivePhotosListingAfterChange],
afterDelete: [revalidateArchivePhotosListingAfterDelete],
},
fields: [
{
name: 'flickrId',
type: 'text',
label: 'ID no Flickr',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Identidade da foto no Flickr — a chave que torna a ingestão idempotente.',
},
},
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do arquivo para acessibilidade.',
},
},
catalogGroup,
{
name: 'publicationStatus',
type: 'select',
label: 'Publicação',
required: true,
index: true,
defaultValue: 'draft',
options: ARCHIVE_PHOTO_PUBLICATION_STATUSES.map((status) => ({
value: status,
label: archivePhotoPublicationStatusLabels[status],
})),
admin: {
description:
'Só "Aprovada" aparece no álbum público (/fotos). "Removida" é o estado de remoção a pedido — a catalogação automática nunca o altera e a foto não volta ao público sem uma nova edição humana.',
},
},
{
name: 'curatedFields',
type: 'select',
label: 'Campos curados pela assessoria',
hasMany: true,
options: ARCHIVE_PHOTO_CURATED_FIELDS.map((value) => ({
value,
label: archivePhotoCuratedFieldLabels[value],
})),
admin: {
readOnly: true,
description:
'O que a assessoria já editou; a catalogação automática nunca sobrescreve estes campos.',
},
},
{
name: 'title',
type: 'text',
label: 'Título no Flickr',
},
{
name: 'description',
type: 'textarea',
label: 'Descrição no Flickr',
},
{
name: 'tags',
type: 'array',
label: 'Tags do Flickr',
labels: { singular: 'Tag', plural: 'Tags' },
fields: [
{
name: 'name',
type: 'text',
label: 'Tag',
required: true,
},
],
},
{
name: 'takenAt',
type: 'text',
label: 'Data da foto',
admin: {
description: 'Data informada pelo Flickr, sem fuso (YYYY-MM-DD HH:MM:SS).',
},
},
{
name: 'takenOn',
type: 'date',
label: 'Dia da foto',
index: true,
admin: {
readOnly: true,
description: 'Derivado da data da foto (meio-dia UTC, sem fuso).',
},
},
{
name: 'postedAt',
type: 'text',
label: 'Publicada em',
admin: {
description: 'Data de publicação no Flickr (ISO 8601, UTC).',
},
},
{
name: 'albums',
type: 'array',
label: 'Álbuns',
labels: { singular: 'Álbum', plural: 'Álbuns' },
fields: [
{
name: 'albumId',
type: 'text',
label: 'ID do álbum',
required: true,
},
{
name: 'title',
type: 'text',
label: 'Título do álbum',
required: true,
},
],
},
{
name: 'geo',
type: 'group',
label: 'Geotag',
fields: [
{
name: 'latitude',
type: 'number',
label: 'Latitude',
},
{
name: 'longitude',
type: 'number',
label: 'Longitude',
},
],
},
{
name: 'exif',
type: 'json',
label: 'EXIF',
admin: {
description: 'Metadados EXIF da foto, como o Flickr respondeu ({ tag, label, value }).',
},
},
{
name: 'sourceUrl',
type: 'text',
label: 'Página no Flickr',
},
{
name: 'owner',
type: 'text',
label: 'Conta (NSID)',
},
{
name: 'license',
type: 'text',
label: 'Licença (Flickr)',
},
{
name: 'municipalityName',
type: 'text',
label: 'Município (nome)',
admin: {
readOnly: true,
description:
'Derivado do município da ficha, para o álbum público ler sem tocar a collection campaign-only.',
},
},
{
name: 'municipalitySlug',
type: 'text',
label: 'Município (slug público)',
index: true,
admin: {
readOnly: true,
description: 'Faceta de município da URL pública (`/fotos?municipio=<slug>`).',
},
},
{
name: 'searchText',
type: 'textarea',
label: 'Texto normalizado (busca)',
admin: {
readOnly: true,
description:
'Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags.',
},
},
facesGroup,
],
upload: true,
}