feat(C244): faceta pessoa do álbum lê o reconhecimento facial

This commit is contained in:
Francisco Solla 2026-10-02 09:09:17 -03:00
parent 625ab5c10e
commit e46ed57e2c
12 changed files with 531 additions and 72 deletions

View file

@ -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

View file

@ -25,7 +25,7 @@ const FACET_MENU_HEADINGS: Record<ArchivePhotoAlbumFacet, string> = {
data: 'Dias com fotos publicadas',
municipio: 'Municípios com fotos',
atividade: 'Atividades',
pessoa: 'Pessoas que aparecem',
pessoa: 'Figuras reconhecidas com curadoria',
}
const FIELD_CLASS =

View file

@ -33,40 +33,49 @@ export const ArchivePhotoNoResults = ({
activeFilters,
}: {
activeFilters: readonly ArchivePhotoAlbumActiveFilter[]
}) => (
<section
data-album-no-results
className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14"
>
<div
aria-hidden="true"
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
}) => {
// C244 — the pessoa facet only names curated public figures recognized by the
// facial layer: the empty state explains that origin instead of promising
// "all photos of a name".
const hasPersonFilter = activeFilters.some((filter) => filter.facet === 'pessoa')
return (
<section
data-album-no-results
className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14"
>
<SearchIcon className="size-5 sm:size-6" strokeWidth={2} />
</div>
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nada encontrado com esses filtros
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.
</p>
{activeFilters.length > 0 ? (
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
{activeFilters.map((filter) => (
<span
key={filter.facet}
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
>
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
</span>
))}
<div
aria-hidden="true"
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
>
<SearchIcon className="size-5 sm:size-6" strokeWidth={2} />
</div>
) : null}
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
>
Limpar filtros
</Link>
</section>
)
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nada encontrado com esses filtros
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
{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.'}
</p>
{activeFilters.length > 0 ? (
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
{activeFilters.map((filter) => (
<span
key={filter.facet}
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
>
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
</span>
))}
</div>
) : null}
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
>
Limpar filtros
</Link>
</section>
)
}

View file

@ -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=<slug>`
* 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<string, string>()
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

View file

@ -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<number, readonly ArchivePhotoPublicPerson[]>,
): 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<ArchivePhotoPublicItem[]> =>
(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<ArchivePhotoPublicItem[]> => {
const [result, photoFigureMatches] = await Promise.all([
getCachedApprovedArchivePhotos()(),
loadApprovedPhotoFigureMap(),
])
const figuresByPhoto = new Map<number, readonly ArchivePhotoPublicPerson[]>()
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

View file

@ -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<ArchivePhotoFaceImage> => {
export const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
const prepared = await sharp(filePath)
.rotate()
.resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' })

View file

@ -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
}

View file

@ -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<PhotoFigureMatch[]> => {
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<PhotoFigureMatch[]> => {
const payload = await getPayload({ config: configPromise })
const notice = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
if (!notice) return []
try {
return await getCachedApprovedPhotoFigureMatches()()
} catch {
return []
}
}

View file

@ -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')

View file

@ -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<string>()
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<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,
}),
)
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([])
})
})

View file

@ -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: [],
}),
]

View file

@ -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',