diff --git a/src/app/(frontend)/fotos/page.tsx b/src/app/(frontend)/fotos/page.tsx index 8554e1b6..a85a3990 100644 --- a/src/app/(frontend)/fotos/page.tsx +++ b/src/app/(frontend)/fotos/page.tsx @@ -23,7 +23,7 @@ import { type ArchivePhotoAlbumSearchParams, } from '@/lib/archivePhotoPublicCatalog' import { - getApprovedArchivePhotoItems, + getApprovedArchivePhotoAlbumItems, hasPublishedArchivePhotos, } from '@/utilities/archivePhotos/archivePhotoReads' import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads' @@ -101,7 +101,7 @@ export default async function FotosPage({ if (album.published === false) notFound() const params = parseArchivePhotoAlbumParams(rawSearchParams) - const approved = await getApprovedArchivePhotoItems() + const approved = await getApprovedArchivePhotoAlbumItems() const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl) ? album.removalChannelUrl.trim() : null diff --git a/src/components/fotos/ArchivePhotoFilters.tsx b/src/components/fotos/ArchivePhotoFilters.tsx index eb027044..437d543d 100644 --- a/src/components/fotos/ArchivePhotoFilters.tsx +++ b/src/components/fotos/ArchivePhotoFilters.tsx @@ -25,7 +25,7 @@ const FACET_MENU_HEADINGS: Record = { data: 'Dias com fotos publicadas', municipio: 'Municípios com fotos', atividade: 'Atividades', - pessoa: 'Pessoas que aparecem', + pessoa: 'Figuras reconhecidas com curadoria', } const FIELD_CLASS = diff --git a/src/components/fotos/ArchivePhotoStates.tsx b/src/components/fotos/ArchivePhotoStates.tsx index 1ab7f1b2..3def8206 100644 --- a/src/components/fotos/ArchivePhotoStates.tsx +++ b/src/components/fotos/ArchivePhotoStates.tsx @@ -33,40 +33,49 @@ export const ArchivePhotoNoResults = ({ activeFilters, }: { activeFilters: readonly ArchivePhotoAlbumActiveFilter[] -}) => ( -
- -

- Nada encontrado com esses filtros -

-

- Tente outro termo ou volte a ver todas as fotos aprovadas do álbum. -

- {activeFilters.length > 0 ? ( -
- {activeFilters.map((filter) => ( - - {filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value} - - ))} + - ) : null} - - Limpar filtros - -
-) +

+ Nada encontrado com esses filtros +

+

+ {hasPersonFilter + ? 'O filtro Pessoa pública considera apenas figuras reconhecidas com curadoria no acervo — nunca terceiros. Tente outra pessoa ou volte a ver todas as fotos aprovadas do álbum.' + : 'Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.'} +

+ {activeFilters.length > 0 ? ( +
+ {activeFilters.map((filter) => ( + + {filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value} + + ))} +
+ ) : null} + + Limpar filtros + + + ) +} diff --git a/src/lib/archivePhotoPublicCatalog.ts b/src/lib/archivePhotoPublicCatalog.ts index 727f6d04..3f59ec08 100644 --- a/src/lib/archivePhotoPublicCatalog.ts +++ b/src/lib/archivePhotoPublicCatalog.ts @@ -10,6 +10,11 @@ * the headings are derived from the items the caller already gated through * `archivePhotoIsPublic`, so a filter that cannot match anything never renders * and the empty states stay honest. + * + * C244 — `people` is no longer the text `catalog.people` of the ficha: it is + * the curated public figures the facial layer recognized in the photo + * (`record.figures`, merged by the album read). The URL key `?pessoa=` + * and the facet rendering stay exactly the C233 contract. */ import { ARCHIVE_PHOTO_SCENES, @@ -19,8 +24,7 @@ import { resolveArchivePhotoScene, type ArchivePhotoScene, } from '@/lib/archivePhotoCatalog' -import { resolvePublicFigureName } from '@/lib/publicFigureCatalog' -import { SLUG_PATTERN, slugify } from '@/lib/slug' +import { SLUG_PATTERN } from '@/lib/slug' import { normalizeForSearch } from '@/lib/speechSearch' export const ARCHIVE_PHOTO_ALBUM_PATH = '/fotos' @@ -180,18 +184,23 @@ export const archivePhotoShortDateLabel = (value: string | null | undefined): st const archivePhotoDayOf = (value: string | null | undefined): string | null => dayPartsOf(value)?.join('-') ?? null -type ArchivePhotoPublicPerson = { slug: string; name: string } +/** + * C244 — one curated public figure recognized in a photo: the `faceFigure` + * slug (the `?pessoa` URL value) and its display name. The album's "Quem + * aparece" and the `pessoa` facet come from the facial layer, never from the + * text `catalog.people` of the ficha. + */ +export type ArchivePhotoPublicPerson = { slug: string; name: string } -/** One appearance: canonical catalog spelling (when it resolves), slug-keyed. */ -const archivePhotoPublicPeople = (names: readonly string[]): ArchivePhotoPublicPerson[] => { +/** Dedupes by slug and drops blank entries; the read already sorts by slug. */ +const archivePhotoPublicFigures = ( + figures: readonly ArchivePhotoPublicPerson[], +): ArchivePhotoPublicPerson[] => { const people = new Map() - for (const raw of names) { - const trimmed = raw.trim() - if (!trimmed) continue - const name = resolvePublicFigureName(trimmed) ?? trimmed - const slug = slugify(name) - if (!slug) continue - people.set(slug, name) + for (const figure of figures) { + const name = figure.name?.trim() + if (!figure.slug || !name) continue + if (!people.has(figure.slug)) people.set(figure.slug, name) } return [...people.entries()].map(([slug, name]) => ({ slug, name })) } @@ -210,6 +219,12 @@ export type ArchivePhotoPublicSource = { scene?: string | null people?: string[] | null } | null + /** + * Curated figures recognized in this photo (C244), merged by the album read. + * Absent in the plain read: the text `catalog.people` remains ficha content + * and never feeds this field. + */ + figures?: readonly ArchivePhotoPublicPerson[] | null } export type ArchivePhotoPublicItem = { @@ -225,7 +240,8 @@ export type ArchivePhotoPublicItem = { municipalitySlug: string | null scene: ArchivePhotoScene | null sceneLabel: string | null - people: string[] + /** Curated figures recognized by the facial layer (C244), slug-keyed. */ + people: ArchivePhotoPublicPerson[] peopleLabel: string | null /** "12 set 2026 · Município · Plenária" — the card's metadata line. */ metaLabel: string @@ -270,7 +286,7 @@ export const toArchivePhotoPublicItem = ( const caption = record.catalog?.caption?.trim() || null const scene = resolveArchivePhotoScene(record.catalog?.scene) const sceneText = scene ? archivePhotoSceneLabel(scene) : null - const people = archivePhotoPublicPeople(record.catalog?.people ?? []) + const people = archivePhotoPublicFigures(record.figures ?? []) const takenOn = record.takenOn ?? null const shortDateLabel = archivePhotoShortDateLabel(takenOn) const municipalityName = record.municipalityName?.trim() || null @@ -288,7 +304,7 @@ export const toArchivePhotoPublicItem = ( municipalitySlug, scene, sceneLabel: sceneText, - people: peopleNames, + people, peopleLabel: archivePhotoPeopleLabel(peopleNames), metaLabel: [shortDateLabel, municipalityName, sceneText].filter(Boolean).join(' · '), searchText: record.searchText ?? '', @@ -350,7 +366,7 @@ export const archivePhotoAlbumFacets = ( municipalities.set(item.municipalitySlug, item.municipalityName) } if (item.scene) scenes.add(item.scene) - for (const person of archivePhotoPublicPeople(item.people)) { + for (const person of item.people) { people.set(person.slug, person.name) } } @@ -422,10 +438,7 @@ export const filterArchivePhotoAlbumItems = ( if (params.data && archivePhotoDayOf(item.takenOn) !== params.data) return false if (params.municipio && item.municipalitySlug !== params.municipio) return false if (params.atividade && item.scene !== params.atividade) return false - if ( - params.pessoa && - !archivePhotoPublicPeople(item.people).some((person) => person.slug === params.pessoa) - ) { + if (params.pessoa && !item.people.some((person) => person.slug === params.pessoa)) { return false } if (!term) return true diff --git a/src/utilities/archivePhotos/archivePhotoReads.ts b/src/utilities/archivePhotos/archivePhotoReads.ts index a8c2fa19..6ffa205e 100644 --- a/src/utilities/archivePhotos/archivePhotoReads.ts +++ b/src/utilities/archivePhotos/archivePhotoReads.ts @@ -3,9 +3,11 @@ import 'server-only' import { toArchivePhotoPublicItem, type ArchivePhotoPublicItem, + type ArchivePhotoPublicPerson, type ArchivePhotoPublicSource, } from '@/lib/archivePhotoPublicCatalog' import { getCollectionListingTag } from '@/utilities/documents' +import { loadApprovedPhotoFigureMap } from '@/utilities/faceIndex/faceFigureReads' import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload } from 'payload' @@ -54,11 +56,43 @@ const getCachedApprovedArchivePhotos = () => tags: [getCollectionListingTag('archivePhoto')], }) +const toApprovedItems = ( + docs: readonly ArchivePhotoPublicSource[], + figuresByPhoto?: ReadonlyMap, +): ArchivePhotoPublicItem[] => + docs + .map((source) => { + if (!figuresByPhoto) return toArchivePhotoPublicItem(source) + return toArchivePhotoPublicItem({ + ...source, + figures: figuresByPhoto.get(source.id) ?? [], + }) + }) + .filter((item): item is ArchivePhotoPublicItem => item !== null) + /** Approved photos as serializable public view models, newest first. */ export const getApprovedArchivePhotoItems = async (): Promise => - (await getCachedApprovedArchivePhotos()()).docs - .map((record) => toArchivePhotoPublicItem(record as ArchivePhotoPublicSource)) - .filter((item): item is ArchivePhotoPublicItem => item !== null) + toApprovedItems((await getCachedApprovedArchivePhotos()()).docs) + +/** + * C244 — the same approved list, enriched with the curated public figures + * recognized in each photo by the facial layer (`loadApprovedPhotoFigureMap`). + * Only the album route uses this: the selfie endpoint and the media route stay + * on the plain read and never pay for the matching. + */ +export const getApprovedArchivePhotoAlbumItems = async (): Promise => { + const [result, photoFigureMatches] = await Promise.all([ + getCachedApprovedArchivePhotos()(), + loadApprovedPhotoFigureMap(), + ]) + + const figuresByPhoto = new Map() + for (const match of photoFigureMatches) { + figuresByPhoto.set(match.photoId, match.figures) + } + + return toApprovedItems(result.docs, figuresByPhoto) +} /** * One approved photo by id (the overlay and the media gate). A draft, a diff --git a/src/utilities/faceIndex/faceDescriptorIndex.ts b/src/utilities/faceIndex/faceDescriptorIndex.ts index 3ff9247a..df3b2bcf 100644 --- a/src/utilities/faceIndex/faceDescriptorIndex.ts +++ b/src/utilities/faceIndex/faceDescriptorIndex.ts @@ -37,7 +37,7 @@ import { /** Long edge sent to the engine; the original never leaves the archive. */ const IMAGE_MAX_EDGE = 1024 -type ArchivePhotoFaceImage = { +export type ArchivePhotoFaceImage = { pixels: Buffer width: number height: number @@ -48,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 browser compute descriptors over, so - * the selfie and the archive enter the same space. + * the exact input both the batch, the C244 figure enrollment and the browser + * compute descriptors over, so every vector enters the same space. */ -const prepareFaceImage = async (filePath: string): Promise => { +export const prepareFaceImage = async (filePath: string): Promise => { const prepared = await sharp(filePath) .rotate() .resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' }) diff --git a/src/utilities/faceIndex/faceDescriptorReads.ts b/src/utilities/faceIndex/faceDescriptorReads.ts index 5b6c40a4..a337bbfa 100644 --- a/src/utilities/faceIndex/faceDescriptorReads.ts +++ b/src/utilities/faceIndex/faceDescriptorReads.ts @@ -10,6 +10,7 @@ import { readFaceVector, type FaceDescriptorEntry, } from '@/lib/faceSearch' +import { revalidateArchivePhotosListing } from '@/utilities/documents' /** * C242 — reads of the anonymous face index. The collection is admin-only and @@ -154,5 +155,9 @@ export const deleteFaceDescriptorMatches = async ({ overrideAccess: true, }) invalidateFaceDescriptorIndex() + // C244 — the curated-figure map of the public album is derived from these + // rows and cached under the album tag: without this bust the person would + // keep being named in the facet after leaving the index. + revalidateArchivePhotosListing() return matchedIds.length } diff --git a/src/utilities/faceIndex/faceFigureReads.ts b/src/utilities/faceIndex/faceFigureReads.ts new file mode 100644 index 00000000..d15144a9 --- /dev/null +++ b/src/utilities/faceIndex/faceFigureReads.ts @@ -0,0 +1,95 @@ +import 'server-only' + +import { unstable_cache } from 'next/cache' +import { getPayload } from 'payload' + +import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys' +import { + findFaceFigureMatches, + groupFaceFigureMatchesByPhoto, + type FaceFigure, + type PhotoFigureMatch, +} from '@/lib/faceFigureCatalog' +import { getConsentByKey } from '@/utilities/campaignConsent' +import { getCollectionListingTag } from '@/utilities/documents' +import { loadFaceDescriptorIndex } from '@/utilities/faceIndex/faceDescriptorReads' +import configPromise from '@payload-config' + +/** + * C244 — the read that turns the curated `faceFigure` catalog plus the C242 + * anonymous descriptor index into the album's per-photo "who appears" map. The + * matching is pure math (`findFaceFigureMatches`) over the already-cached + * descriptor index; the result is cached under the album listing tag, so a + * figure edit, a photo lifecycle write or a batch run that busts `archivePhotos` + * recomputes it once — never per visit. + * + * The public-notice Consent is checked OUTSIDE the cache and fails closed: with + * no `busca-selfie-indice` row the map is empty (nobody is named), and + * provisioning the notice takes effect immediately, without a deploy. + */ + +const faceFigureSelect = { + name: true, + slug: true, + active: true, + references: true, +} as const + +const toFaceFigure = (doc: { + name: string + slug: string + active?: boolean | null + references?: readonly { model?: string | null; vector?: unknown }[] | null +}): FaceFigure => ({ + slug: doc.slug, + name: doc.name, + active: doc.active ?? null, + references: (doc.references ?? []).map((reference) => ({ + model: reference.model ?? null, + vector: reference.vector, + })), +}) + +const computeApprovedPhotoFigureMatches = async (): Promise => { + const payload = await getPayload({ config: configPromise }) + const figuresResult = await payload.find({ + collection: 'faceFigure', + where: { active: { equals: true } }, + depth: 0, + limit: 0, + pagination: false, + select: faceFigureSelect, + // Intentional bypass: the public album read has no actor; the selector is + // the curated catalog itself (admin-only collection). + overrideAccess: true, + }) + + const figures = figuresResult.docs.map(toFaceFigure) + // No reference at all: nobody can match, so the descriptor index is not read. + if (!figures.some((figure) => (figure.references ?? []).length > 0)) return [] + + const descriptors = await loadFaceDescriptorIndex(payload) + return groupFaceFigureMatchesByPhoto(findFaceFigureMatches({ figures, descriptors })) +} + +const getCachedApprovedPhotoFigureMatches = () => + unstable_cache(computeApprovedPhotoFigureMatches, ['archive-photo-figures'], { + tags: [getCollectionListingTag('archivePhoto')], + }) + +/** + * The per-photo curated figures of the approved album, or an empty map when the + * public notice Consent is missing or the index cannot be read — the facet and + * "Quem aparece" disappear; the album itself never breaks. + */ +export const loadApprovedPhotoFigureMap = async (): Promise => { + const payload = await getPayload({ config: configPromise }) + const notice = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY) + if (!notice) return [] + + try { + return await getCachedApprovedPhotoFigureMatches()() + } catch { + return [] + } +} diff --git a/tests/int/collectionAccessLockdown.int.spec.ts b/tests/int/collectionAccessLockdown.int.spec.ts index f64acec4..89d49387 100644 --- a/tests/int/collectionAccessLockdown.int.spec.ts +++ b/tests/int/collectionAccessLockdown.int.spec.ts @@ -194,7 +194,7 @@ describe('CMS/PII collection access lockdown', () => { expect(authed && 'roles' in authed ? authed.roles : undefined).toEqual(['editor']) }) - it.each(['signature', 'subscription', 'consent'] as const)( + it.each(['signature', 'subscription', 'consent', 'faceFigure'] as const)( 'denies campaign users and anonymous requests access to %s (PII/legal)', async (collection) => { const coordinator = await campaignFixtures().createCampaignUser('coordinator') diff --git a/tests/int/faceFigureReads.int.spec.ts b/tests/int/faceFigureReads.int.spec.ts new file mode 100644 index 00000000..d442d41a --- /dev/null +++ b/tests/int/faceFigureReads.int.spec.ts @@ -0,0 +1,284 @@ +// @vitest-environment node + +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const { revalidateTagMock } = vi.hoisted(() => ({ revalidateTagMock: vi.fn() })) + +vi.mock('next/cache', () => ({ + revalidatePath: () => undefined, + revalidateTag: revalidateTagMock, + 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 { getApprovedArchivePhotoAlbumItems } from '@/utilities/archivePhotos/archivePhotoReads' +import { + deleteFaceDescriptorMatches, + invalidateFaceDescriptorIndex, +} from '@/utilities/faceIndex/faceDescriptorReads' +import { loadApprovedPhotoFigureMap } from '@/utilities/faceIndex/faceFigureReads' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { + createFaceTestPhoto, + FACE_INDEX_LEASE_KEY, + FACE_SPECS_LEASE_KEY, + faceTestVector, + indexFaceTestPhoto, +} from '../helpers/faceIntFixtures' +import { + acquireTestDatabaseLease, + ensureLeasedConsent, + PHOTO_ALBUM_LEASE_KEY, + withExclusiveTestDatabaseLease, +} from '../helpers/testDatabaseLease' + +// C244 — the curated public-figure layer over the real Payload `teqo_test`: +// the album read names only figures of the curated catalog recognized in +// APPROVED photos, the text `catalog.people` never feeds the facet, and the +// Consent notice, unapproval and the opt-out all fail closed. + +let payload: Payload +const createdFlickrIds = new Set() +const createdFigureIds: number[] = [] +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: 'c244', + trackedFlickrIds: createdFlickrIds, + tempDirs, + }) + +/** + * Direction-unique descriptor: 0.5 at one index, zero elsewhere. Two different + * indices sit at ~0.707 euclidean distance (above the 0.45 threshold), so each + * test's figure matches only its own photo even though the specs share the + * test database. + */ +let nextVectorIndex = 0 +const uniqueVectorIndex = (): number => (nextVectorIndex += 1) + +const directionVector = (index: number): number[] => { + const vector = faceTestVector(0) + vector[index % FACE_SEARCH_DESCRIPTOR_LENGTH] = 0.5 + return vector +} + +const createFigure = async ({ + index, + active = true, + model = FACE_SEARCH_MODEL, +}: { + index: number + active?: boolean + model?: string +}): Promise<{ slug: string; name: string }> => { + const suffix = randomUUID().slice(0, 8) + const slug = `figura-${suffix}` + const name = `Figura ${suffix}` + const figure = await payload.create({ + collection: 'faceFigure', + data: { + name, + slug, + active, + references: [{ model, vector: directionVector(index), source: 'retrato de teste (C244)' }], + }, + overrideAccess: true, + }) + createdFigureIds.push(figure.id) + return { slug, name } +} + +const albumItemOf = async (photoId: number) => + (await getApprovedArchivePhotoAlbumItems()).find((item) => item.id === photoId) ?? null + +describe('curated public-figure album read (C244)', () => { + let specsLease: Awaited> + + 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, + }), + ) + await ensureLeasedConsent(payload, { + consentKey: FACE_INDEX_CONSENT_KEY, + leaseKey: FACE_INDEX_LEASE_KEY, + }) + }) + + afterAll(async () => { + for (const flickrId of createdFlickrIds) { + await payload.delete({ + collection: ARCHIVE_PHOTO_SLUG, + where: { flickrId: { equals: flickrId } }, + overrideAccess: true, + }) + } + for (const id of createdFigureIds) { + await payload.delete({ collection: 'faceFigure', id, overrideAccess: true }) + } + invalidateFaceDescriptorIndex() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + await specsLease.release() + }) + + it('names a curated figure only in the photos where the face was indexed', async () => { + const index = uniqueVectorIndex() + const figure = await createFigure({ index }) + const matched = await createPhoto(true) + const unmatched = await createPhoto(true) + + await indexFaceTestPhoto({ + payload, + photoId: matched.id, + descriptors: [directionVector(index)], + }) + await indexFaceTestPhoto({ + payload, + photoId: unmatched.id, + descriptors: [directionVector(uniqueVectorIndex())], + }) + // The text catalog of the matched photo names somebody else: the facet must + // ignore it (C244 source change). + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: matched.id, + data: { catalog: { people: ['Pessoa Só no Texto'] } }, + overrideAccess: true, + }) + invalidateFaceDescriptorIndex() + + const matchedItem = await albumItemOf(matched.id) + expect(matchedItem?.people).toEqual([{ slug: figure.slug, name: figure.name }]) + expect(matchedItem?.peopleLabel).toBe(figure.name) + + const unmatchedItem = await albumItemOf(unmatched.id) + expect(unmatchedItem?.people).toEqual([]) + expect(unmatchedItem?.peopleLabel).toBeNull() + }) + + it('ignores an inactive figure and a reference of another model', async () => { + const inactive = await createFigure({ index: uniqueVectorIndex(), active: false }) + const foreignModel = await createFigure({ + index: uniqueVectorIndex(), + model: 'outro-modelo@1', + }) + const photo = await createPhoto(true) + await indexFaceTestPhoto({ + payload, + photoId: photo.id, + descriptors: [directionVector(uniqueVectorIndex())], + }) + invalidateFaceDescriptorIndex() + + const item = await albumItemOf(photo.id) + expect(item?.people).toEqual([]) + expect(item?.people.map((person) => person.slug)).not.toContain(inactive.slug) + expect(item?.people.map((person) => person.slug)).not.toContain(foreignModel.slug) + }) + + it('fails closed with no figure names while the public notice Consent is missing', async () => { + const index = uniqueVectorIndex() + const figure = await createFigure({ index }) + const photo = await createPhoto(true) + await indexFaceTestPhoto({ payload, photoId: photo.id, descriptors: [directionVector(index)] }) + invalidateFaceDescriptorIndex() + + expect((await loadApprovedPhotoFigureMap()).length).toBeGreaterThan(0) + + try { + await payload.delete({ + collection: 'consent', + where: { key: { equals: FACE_INDEX_CONSENT_KEY } }, + overrideAccess: true, + }) + + expect(await loadApprovedPhotoFigureMap()).toEqual([]) + const item = await albumItemOf(photo.id) + expect(item?.people).toEqual([]) + } finally { + await ensureLeasedConsent(payload, { + consentKey: FACE_INDEX_CONSENT_KEY, + leaseKey: FACE_INDEX_LEASE_KEY, + }) + } + + const restored = await albumItemOf(photo.id) + expect(restored?.people).toEqual([{ slug: figure.slug, name: figure.name }]) + }) + + it('drops the figure names when the photo stops being approved', async () => { + const index = uniqueVectorIndex() + const figure = await createFigure({ index }) + const photo = await createPhoto(true) + await indexFaceTestPhoto({ payload, photoId: photo.id, descriptors: [directionVector(index)] }) + invalidateFaceDescriptorIndex() + + expect((await albumItemOf(photo.id))?.people).toEqual([ + { slug: figure.slug, name: figure.name }, + ]) + + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + data: { publicationStatus: 'removed' }, + overrideAccess: true, + }) + // The descriptor read has a short in-process TTL (C240); a fresh read sees + // the purge immediately, exactly like a new request after the cache window. + invalidateFaceDescriptorIndex() + + expect(await albumItemOf(photo.id)).toBeNull() + expect((await loadApprovedPhotoFigureMap()).some((match) => match.photoId === photo.id)).toBe( + false, + ) + }) + + it('removes the figure name after the opt-out and busts the album cache', async () => { + const index = uniqueVectorIndex() + const figure = await createFigure({ index }) + const photo = await createPhoto(true) + const vector = directionVector(index) + await indexFaceTestPhoto({ payload, photoId: photo.id, descriptors: [vector] }) + invalidateFaceDescriptorIndex() + + expect((await albumItemOf(photo.id))?.people).toEqual([ + { slug: figure.slug, name: figure.name }, + ]) + + revalidateTagMock.mockClear() + const deleted = await deleteFaceDescriptorMatches({ payload, vector }) + + expect(deleted).toBe(1) + expect(revalidateTagMock).toHaveBeenCalledWith('archivePhotos') + expect((await loadApprovedPhotoFigureMap()).some((match) => match.photoId === photo.id)).toBe( + false, + ) + expect((await albumItemOf(photo.id))?.people).toEqual([]) + }) +}) diff --git a/tests/unit/archivePhotoPublicCatalog.unit.spec.ts b/tests/unit/archivePhotoPublicCatalog.unit.spec.ts index 460d1c99..eb114097 100644 --- a/tests/unit/archivePhotoPublicCatalog.unit.spec.ts +++ b/tests/unit/archivePhotoPublicCatalog.unit.spec.ts @@ -41,6 +41,7 @@ const source = ( scene: 'plenaria', people: ['Jorge Solla'], }, + figures: [{ slug: 'jorge-solla', name: 'Jorge Solla' }], ...overrides, }) @@ -143,7 +144,11 @@ describe('toArchivePhotoPublicItem', () => { it('builds the card/dialog view: title, meta line and people label', () => { const photo = item(1, { - catalog: { caption: null, scene: 'reuniao', people: ['Jorge Solla', 'Rui Costa'] }, + catalog: { caption: null, scene: 'reuniao', people: [] }, + figures: [ + { slug: 'jorge-solla', name: 'Jorge Solla' }, + { slug: 'rui-costa', name: 'Rui Costa' }, + ], }) expect(photo.title).toBe('Descrição da foto 1') @@ -161,12 +166,21 @@ describe('toArchivePhotoPublicItem', () => { expect(photo.downloadFilename).toBe('jorge-solla-1313-foto-9.jpg') }) - it('canonicalizes a catalog spelling on the people facet and collapses duplicates', () => { - const photo = item(1, { - catalog: { caption: null, scene: 'plenaria', people: ['rui costa', 'Rui Costa'] }, + it('derives people only from the curated figures (never the text catalog) and dedupes by slug', () => { + const textOnly = item(1, { + catalog: { caption: null, scene: 'plenaria', people: ['Rui Costa'] }, + figures: [], + }) + const duplicated = item(2, { + figures: [ + { slug: 'rui-costa', name: 'Rui Costa' }, + { slug: 'rui-costa', name: 'rui costa' }, + { slug: '', name: 'Sem slug' }, + ], }) - expect(photo.people).toEqual(['Rui Costa']) + expect(textOnly.people).toEqual([]) + expect(duplicated.people).toEqual([{ slug: 'rui-costa', name: 'Rui Costa' }]) }) }) @@ -179,6 +193,7 @@ describe('archivePhotoAlbumFacets / filter / heading', () => { municipalitySlug: 'salvador', searchText: 'visita posto salvador rui costa', catalog: { caption: 'Visita', scene: 'visita', people: ['Rui Costa'] }, + figures: [{ slug: 'rui-costa', name: 'Rui Costa' }], }), item(3, { takenOn: null, @@ -186,6 +201,7 @@ describe('archivePhotoAlbumFacets / filter / heading', () => { municipalitySlug: null, searchText: 'retrato gabinete', catalog: { caption: 'Retrato', scene: 'retrato', people: [] }, + figures: [], }), ] diff --git a/tests/unit/faceSearch.unit.spec.ts b/tests/unit/faceSearch.unit.spec.ts index 46921149..d3b5c13c 100644 --- a/tests/unit/faceSearch.unit.spec.ts +++ b/tests/unit/faceSearch.unit.spec.ts @@ -98,7 +98,10 @@ describe('toFaceSearchPhotoView', () => { municipalitySlug: 'feira-de-santana', scene: 'plenaria', sceneLabel: 'Plenária', - people: ['Jorge Solla', 'Outra Pessoa'], + people: [ + { slug: 'jorge-solla', name: 'Jorge Solla' }, + { slug: 'outra-pessoa', name: 'Outra Pessoa' }, + ], peopleLabel: 'Jorge Solla e Outra Pessoa', metaLabel: '12 set 2026 · Feira de Santana · Plenária', searchText: 'plenaria feira de santana',