diff --git a/.env.example b/.env.example index 9f6761a8..9e0f266c 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,14 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm build NEXT_PUBLIC_CARDS_CUTOUT_STUB= +# C234 — test seam for the on-device selfie-search engine. `1` swaps the +# face-api engine (and its ~7 MB of same-origin models) for a deterministic +# descriptor derived from the file bytes (e2e builds and the Playwright +# webServer only; the real engine must run in dev/production). Local E2E_PROD=1 +# builds set it too: +# NEXT_PUBLIC_FACE_SEARCH_STUB=1 pnpm build +NEXT_PUBLIC_FACE_SEARCH_STUB= + # Shared secret guarding POST /api/revalidate, which busts ISR cache tags on demand. # Needed after direct-DB writes (e.g. `pnpm db:seed:posts`, Onda 0 migrations) since those # bypass the deployed Payload hooks. Default tag is `posts`; allowlisted tags also include diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index d08822fc..8f508df8 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -224,6 +224,7 @@ jobs: # S15 — deterministic cutout stub: the e2e build never downloads the # MediaPipe wasm nor runs inference. NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1' + NEXT_PUBLIC_FACE_SEARCH_STUB: '1' - name: E2E tests (blast radius — manifest-selected or curated specs) if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' # OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 82e2b973..cf4386bd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -198,6 +198,7 @@ jobs: # S15 — deterministic cutout stub: the e2e build never downloads the # MediaPipe wasm nor runs inference. NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1' + NEXT_PUBLIC_FACE_SEARCH_STUB: '1' - name: E2E tests (full suite, single job, 4 workers) # Explicit CI workers; the local default stays 2 (playwright.config.ts). # No `--` separator: pnpm would forward it verbatim to playwright and diff --git a/.gitignore b/.gitignore index 16bd3f49..47e19ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ next-env.d.ts /data/acervo-index/ # C232 — photo-archive cataloguing receipts (pnpm archive:catalog) /data/archive/ +/data/face/ # Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs) /data/solla-ceuci-salvador/ @@ -154,6 +155,7 @@ next-env.d.ts # S15 — MediaPipe wasm runtime, copied from node_modules at dev/build time /public/cards/mediapipe/ +/public/fotos/modelos/ /data/encode/ /data/poster-candidates/ diff --git a/AGENTS-public.md b/AGENTS-public.md index f494e5dc..63276a64 100644 --- a/AGENTS-public.md +++ b/AGENTS-public.md @@ -6,6 +6,14 @@ 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 + +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 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`). + +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. + ## Posts & Tags (news / publications) The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`. diff --git a/Dockerfile b/Dockerfile index 821e24a8..599ee9c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 # same-origin (the files are gitignored build artifacts, never committed). RUN node scripts/copy-card-vision-assets.mjs +# C234 — copy the face-api model weights into public/ so the selfie search runs +# same-origin too (same gitignored-artifact contract). +RUN node scripts/copy-face-vision-assets.mjs + # OPS99: Generate importMap with dummy S3_* envs BEFORE next build. # This ensures the production image always has the S3ClientUploadHandler entry, # eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73). diff --git a/docs/changelog/2026-09-27-c234.md b/docs/changelog/2026-09-27-c234.md new file mode 100644 index 00000000..af93cd13 --- /dev/null +++ b/docs/changelog/2026-09-27-c234.md @@ -0,0 +1 @@ +**C234 — Busca por selfie — visitante encontra as fotos em que aparece (2026-09-27):** o álbum público do C233 ganha a busca por selfie **fechada por padrão**: `/fotos/encontre` (noindex, 404 com a flag `photoAlbum.selfieSearchEnabled` desligada — default `false` — ou o álbum fechado; estado fail-closed da cena 7 sem o Consent) e `POST /api/fotos/selfie`, que aceita **só o vetor 128-d** (JSON ≤4 KB, same-origin, rate limit 20/10 min por IP hasheado com chave própria `selfie:` sobre o dono do beacon; sem imagem, sem score, sem nomes de terceiros). A selfie nunca sai do aparelho: o engine `@vladmandic/face-api` (mesmos três nets do CLI, `tinyFaceDetector`+`faceLandmark68Net`+`faceRecognitionNet`, opções compartilhadas em `src/lib/faceSearch.ts`) roda no browser com os modelos same-origin (`/fotos/modelos`, artefato de build copiado por `scripts/copy-face-vision-assets.mjs` no prebuild/predev/Docker) e no lote pela build node-wasm (sem `tfjs-node`, sem browser headless). **Escopo A/C do gate (PR #1370): só quem aderiu é indexado; índice anônimo do acervo não existe.** A collection nova `faceSubject` (admin-only, sem vínculo com `Contact`) guarda o descriptor consentido, o snapshot do `consentHash` de `busca-selfie-indice` e os vínculos derivados `matchedPhotos`; o enrollment e a indexação são ops-only (`pnpm faces:enroll`, `pnpm faces:index --apply|--verify`, guardas C231/C232, recibos sem vetor/bytes em `data/face/reports/`), com descriptors do acervo transitórios (nunca persistidos) e o marker `archivePhoto.faces.checkedKey` (modelo + `enrolledAt` de cada sujeito elegível — os writes do próprio lote não invalidam a revisão). A consulta resolve os dois 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), exige `status: active` + modelo corrente + hash vigente (texto editado ⇒ inelegível até re-consentimento) e cruza o match com o read aprovado do C233 (draft/removida nunca aparecem; a foto removida some sem deploy). Opt-out self-service por reconhecimento ("Minha presença" → `leave-index` apaga vetor e vínculos, status `removed`; sem match, encaminha ao canal de remoção do álbum). Migration aditiva `add_face_subject` (tabela + join + colunas do marker + flag do global; SQL revisado; nada do acervo muda). UI portada classe-a-classe do design estendido (entrada entre hero e facetas com o submit do álbum secundário, consentimento com o richText do Consent, escolha com prévia local, processando, resultado em grade larga sem "Quem aparece", vazio honesto A/C, presença + diálogo destrutivo com trap de Tab, e os estados de navegador incompatível/erro/limite/rede) e **`Design tier: openai/gpt-5.6-sol` com paridade certificada** contra o app renderizado (3 rodadas, 390/1280). Testes: unit do contrato puro/limiar/schema/parsers/guardas, int do enrollment+índice e do endpoint sobre `teqo_test` (só aprovadas, sem nomes/score, opt-out, rate limit, same-origin), e2e novo `frontendFotosSelfie` (manifest + curado + projeto, engine stubada) e canário manual do engine real (enroll→index→vínculo). Runbook §C234 e contrato em `AGENTS-public.md`. Impl plan: `docs/plans/busca-fotos-por-selfie-impl.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index e42ebc6f..f285eb0f 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1429,6 +1429,65 @@ 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 + +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). + +### Abertura (depois do aval jurídico/DPIA registrado) + +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): + ```bash + cd ~/stack + # plano (dry-run: detecta o rosto e resolve o consentimento; não grava) + docker compose --profile maintenance run --rm -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:enroll --label "" --selfie /app/data/face-selfies/.jpg + # grava (exige TEQO_ENV=production e FACE_ENROLL_CONFIRM=1): + docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_ENROLL_CONFIRM=1 -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face-reports teqo-1313-migrate pnpm faces:enroll --label "" --selfie /app/data/face-selfies/.jpg --apply + ``` +3. Indexe o acervo aprovado para os inscritos (canário primeiro): + ```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 + ``` +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. + +### Remoção e re-consentimento + +- **Sair do índice**: a própria pessoa usa "Minha presença" no fluxo (selfie + confere o vínculo; o vetor e os links são apagados e o status vira + `removed`). O pedido de **remoção de foto** continua no canal do álbum. +- **Texto do Consent editado**: os inscritos ficam inelegíveis até + re-consentimento (o `consentHash` do enrollment é comparado com o texto + vigente) — re-rode `faces:enroll --subject ` com a pessoa. +- **Troca de modelo do engine**: `FACE_SEARCH_MODEL` muda ⇒ o índice inteiro + fica stale; re-inscreva (`--subject`) e rode `faces:index --refresh`. +- **Rollback**: desligue a flag; a migration `add_face_subject` é aditiva + (tabela `face_subject`, colunas `faces_*` em `archive_photo` e + `selfie_search_enabled` no global). Nada do acervo público muda. + +### 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`). + ## Referências - `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`) diff --git a/docs/plans/busca-fotos-por-selfie-impl.md b/docs/plans/busca-fotos-por-selfie-impl.md new file mode 100644 index 00000000..4d5e796b --- /dev/null +++ b/docs/plans/busca-fotos-por-selfie-impl.md @@ -0,0 +1,250 @@ +# Impl: C234 — Busca por selfie — visitante encontra as fotos em que aparece + +Status: aprovado (modo `--auto`) — hard-stops pendentes de aprovação humana explícita na sessão (migração de schema e contrato público) +Atualizado em: 2026-09-27 +Issue: #1369 +Intenção: docs/plans/busca-fotos-por-selfie.md +Design UI (gate): docs/plans/busca-fotos-por-selfie-ui-design.html +Appetite restante: herdado (~2–3 dias eng + ops de enrollment/index + aval jurídico de produto) — **uma superfície nova de biometria sobre o acervo aprovado do C232/C233** (`/fotos/encontre` + `POST /api/fotos/selfie`), sem segundo cadastro de pessoa. Cortes explícitos para caber: enrollment e indexação são **ops-only (CLI)**; sem UI de enrollment; sem captcha; sem modo B (rostos anônimos do acervo); sem retenção de selfie; sem score. + +> Aprovado pelo próprio agente via `--auto` (modo autônomo do `work-issue`); os hard-stops continuam exigindo aprovação humana explícita e estão **pendentes** (migração de schema, contrato público, Consent/DPIA, dependência de engine e enrollment ops-only). **Decisão do gate (PR #1370): "Escopo do índice facial A/C no MVP (B só com DPIA + aval jurídico)".** Interpretação canônica a implementar: **só entram no índice rostos de pessoas que aderiram/consentiram**; a busca por selfie só devolve se o rosto consultante já estiver indexado; **índices anônimos de rostos do acervo são proibidos** (isso é B). Timing: superfície pública de biometria **não abre** sem revisão jurídica — o código nasce **fechado** (flag própria default `false` + Consent fail-closed); a pessoa geral (não indexada) recebe **vazio honesto**. + +## Leitura da intenção + +- **Outcome:** com consentimento ativo e adesão ao índice, o visitante tira/envia uma selfie, o reconhecimento roda no dispositivo (a imagem nunca sai dele) e ele recebe somente as fotos **públicas aprovadas** em que aparece — sem nomes de terceiros e sem score; sem consentimento configurado o fluxo recusa com linguagem clara (nem na UI nem no servidor); a remoção (sair do índice / despublicar foto) funciona e fica registrada. +- **O que NÃO negociar:** + - **Escopo A/C travado pelo gate:** índice só de rostos consentidos; busca que só devolve para rosto já indexado; proibido indexar rostos anônimos do acervo (B) sem DPIA + aval jurídico. + - **Fail-closed de Consent/LGPD:** sem Consent resolvido por `key` estável, o fluxo não habilita — nem na UI, nem no servidor (conceito travado do repo). + - **A selfie nunca sai do dispositivo:** ao servidor vai só o vetor (128 floats), nunca imagem; sem upload, sem galeria, sem retenção. + - **Resposta mínima:** só as fotos aprovadas da pessoa consultante; nunca “outras pessoas parecidas”, nunca nome de terceiro, nunca score/percentual em nenhum estado. + - **Nada de foto privada/bruta:** o resultado é o mesmo acervo aprovado do C232/C233 (`publicationStatus: 'approved'`, `archivePhotoIsPublic` — `src/lib/archivePhotoCatalog.ts:128-129`). + - **Sem cadastro paralelo:** `faceSubject` não é pessoa; sem vínculo com `Contact`/leadership; mínimo PII (label interno + consent + vetor). + - **Antiabuso anônimo** (rate limit, same-origin, body cap), sem captcha no MVP. +- **O que reavaliar (hipóteses da “Direção no codebase”):** + - A intenção cita `src/utilities/ai/rateLimit.ts` como antiabuso; o dono real do throttle anônimo por IP é `src/utilities/content/contentEventRateLimit.ts` (D8) — `ai/rateLimit.ts` é por usuário de chat, não serve. + - A resposta do C233 inclui `people`/`peopleLabel` (`src/lib/archivePhotoPublicCatalog.ts:208-230,285`) — o view model do C234 **precisa** cortar esses campos (D5/D9). + - O CTA “Encontre você nas fotos” foi deliberadamente removido do artefato do C233 (`src/components/fotos/ArchivePhotoHero.tsx:3-6`) — o C234 traz a entrada própria (D9). + - “Upload consentido com retenção zero” da intenção: **rejeitado** — o caminho é vetor-only (sem upload), o que já satisfaz o aceite por construção. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph OPS["Ops — assessoria · CLI na workstation"] + ENR["pnpm faces:enroll\nselfie + consent"] --> SUB[("faceSubject\nlabel · vector 128d\nconsentHash · status")] + IDX["pnpm faces:index --apply"] --> DLD["downloadPrivateMediaToFile\nsharp ≤1024px"] + DLD --> NODE["engine node-wasm\n@vladmandic/face-api"] + NODE --> CMP["distância euclidiana\nthreshold interno 0.45"] + SUB --> CMP + CMP --> LNK["matchedPhotos\njoin faceSubject↔archivePhoto\ntransação + fresh-read"] + end + subgraph PUB["Público"] + ALB["/fotos · C233"] -- "CTA cena 1" --> ENC["/fotos/encontre\nRSC: publicado? + flag + Consent\nfail-closed · noindex"] + ENC --> BRW["engine on-device (lazy)\nselfie NUNCA sai do aparelho"] + BRW -- "só o vetor 128d · JSON ≤4KB" --> API["POST /api/fotos/selfie\nsame-origin · rate limit\nintent search|leave-index"] + API --> ELG["faceSubject ativos\nconsentHash vigente"] + ELG --> APR["getApprovedArchivePhotoItems\n∩ matchedPhotos"] + APR -- "view sem nomes de terceiros\nsem score · limite 60" --> ENC + API -- "leave-index" --> OUT["status removed\nvector null · removedAt\nmatchedPhotos limpo"] + end +``` + +**Opções consideradas (abordagem geral):** A) engine único `@vladmandic/face-api` nos dois lados (browser on-device + CLI node-wasm), subjects consentidos em collection nova, vínculos fotos↔subject calculados em lote CLI e consultados por um route handler novo; B) engine/server-side de terceiro com upload da selfie; C) serviço separado (Python/Rust) ou `MediaPipe` no browser. +**Recomendação:** **A** — mantém a selfie no aparelho (o aceite de produto), usa a mesma família de modelo e a mesma matemática no browser e no CLI, reusa os donos existentes (approval gate do C233, `downloadPrivateMediaToFile`, `withPayloadTransaction`, `getConsentByKey`, guardas de CLI do C231/C232) e cabe no appetite sem infra nova. +**Rejeitadas:** **B** porque manda biometria de cidadão a terceiro e a selfie sairia do dispositivo (anti-goal explícito); **C** porque cria runtime/serviço novo fora do appetite e sem precedente no repo. + +### Componentes / mudanças + +**Novos — schema/dados (na MESMA migration aditiva `add_face_subject`)** + +- **`src/collections/FaceSubject.ts`** (novo): slug `faceSubject`, labels “Pessoa no índice facial”/“Pessoas no índice facial”, grupo `Comunicação` (mesmo dono do acervo, `ArchivePhoto.ts:286-288`), sem versões; access `payloadAdminOnly` nas quatro operações (precedente `ArchivePhoto.ts:300-305`); campos: `label` (text, required; nome de uso interno — nunca público), `consent` (relationship → `consent`, required), `consentHash` (text, readOnly; snapshot de `hashConsentContent` via `getConsentByKey`, `src/utilities/campaignConsent.ts:72-100`), `vector` (json, `hidden` + readOnly; 128 floats — nunca sai do servidor), `model` (text, readOnly; ex. `face-api@1.7.15/faceRecognitionNet`), `status` (select `active|removed`, default `active`, index), `removedAt` (date, readOnly), `matchedPhotos` (relationship hasMany → `archivePhoto`, readOnly; join table). Sem hooks. +- **`src/collections/ArchivePhoto.ts`** (editar `:311-505`): grupo `faces` (`hidden: true`) com `checkedAt` (date) e `checkedKey` (text), readOnly — o marker de incremental do lote (D4). `defaultColumns`/access/hooks intocados. +- **`src/globals/PhotoAlbum.ts`** (editar `:61-80`): campo `selfieSearchEnabled` (checkbox, **default `false`**) — kill switch independente da busca por selfie (o Consent referenciado não pode ser apagado enquanto houver subject, `Consent.ts:8-52` estendido abaixo; sem a flag o “desligar” não teria dono). +- **`src/collections/Consent.ts`** (editar `:24-90`): somar `faceSubject` às contagens do `beforeDelete` (o Consent da adesão não pode ser apagado por baixo dos subjects). +- **Migration `add_face_subject`** (`src/migrations/_add_face_subject.ts|.json` + registro no fim de `src/migrations/index.ts`): tabela `face_subject` + join `face_subject_rels`, colunas `faces_checked_at`/`faces_checked_key` em `archive_photo` e `selfie_search_enabled` no global `photo_album`; SQL revisado à mão; nenhuma migration existente tocada; `pnpm migrate:create add_face_subject` → `pnpm migrate` só no banco do worktree → `pnpm generate:types`. + +**Novos — contrato puro (client-safe, sem I/O)** + +- **`src/lib/faceSearch.ts`** (novo): `FACE_SEARCH_DESCRIPTOR_LENGTH = 128`, `FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'`, `FACE_SEARCH_MAX_DISTANCE = 0.45` (interno, nunca devolvido), `FACE_SEARCH_RESULT_LIMIT = 60`, `FACE_SEARCH_INTENTS = ['search','leave-index']`, `faceEuclideanDistance(a,b)`, `findFaceMatch({ vector, subjects })` (melhor subject sob o threshold), `faceSubjectIsEligible(subject, { model, consentHash })` (ativo + modelo corrente + hash vigente), `toFaceSearchPhotoView(item)` (corta `people`/`peopleLabel`), `faceStubDescriptorFromBytes(bytes)` (seam determinístico do e2e). +- **`src/lib/schemas/faceSearch.ts`** (novo): `z.strictObject` do body `{ vector: number[128] finitos, intent: enum }` — fail-closed (mold `src/lib/schemas/contentEvent.ts:37-59`). +- **`src/lib/campaignConsentKeys.ts`** (editar `:1-40`): `FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'`, `FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'` + mensagens de ausência (padrão `:16-19`); comentário de cabeçalho ajustado (o arquivo já hospeda a key pública do WhatsApp, `:26`). + +**Novos — server-side Payload** + +- **`src/utilities/faceSubjects/faceSubjectReads.ts`** (novo, `server-only`): `listSearchableFaceSubjects(payload)` (query `status=active`, `select` explícito de `vector`/`consentHash`/`model`/`matchedPhotos`, `depth 0`, bypass anônimo documentado como `archivePhotoReads.ts:13-24`) e `listFaceSubjects` (para `--verify`). **Sem `unstable_cache`**: a tabela é pequena e o opt-out precisa valer imediatamente (D5). +- **`src/utilities/faceSubjects/faceSubjectEnrollment.ts`** (novo, `server-only`): `enrollFaceSubject({ payload, label, descriptor, consent, subjectId? })` — grava/atualiza com `overrideAccess: true` (CLI sem sessão) e `context: { faceIndex: true }` (precedente `src/utilities/flickr/archivePhotoCatalog.ts:258-267`). +- **`src/utilities/faceSubjects/faceSubjectPhotoIndex.ts`** (novo, `server-only`): `listArchivePhotoFaceIndexQueue({ payload, refresh, subjectId })` (aprovadas + marker) e `indexArchivePhotoFaces({ payload, item, analyze })` — baixa bytes com `downloadPrivateMediaToFile` + `resolvePrivateMediaStaticDir` (`src/utilities/privateMedia/privateMediaResponse.ts:314-344,45`), `sharp` ≤1024px, descriptors **transitórios**, e dentro de `withPayloadTransaction` (`src/utilities/payloadTransaction.ts:86-113`) relê os subjects e **substitui** os vínculos `matchedPhotos` (adiciona os que casaram, remove o vínculo que deixou de casar); grava `faces.checkedAt`/`checkedKey`. Analyzer injetado (molde C232). +- **`src/utilities/content/contentEventRateLimit.ts`** (editar `:17-25,53-73`): budget parametrizável `checkContentEventRateLimit(key, budget)` com o default atual intocado (beacon C213) e budget estrito para a busca (D8). +- **`src/utilities/boundedRequestBody.ts`** (novo): extrai `readBoundedBody` de `src/app/(frontend)/api/content-events/route.ts:47-78` (cap de bytes em streaming); o route de content-events passa a importar (comportamento idêntico) e o route novo reusa — sem segundo twin. + +**Novos — rotas públicas** + +- **`src/app/(frontend)/fotos/encontre/page.tsx`** (novo): RSC — `photoAlbum.published === false` → 404; `selfieSearchEnabled !== true` → 404; Consent ausente → 200 com o estado fechado da cena 7; resolve o texto do Consent (renderizado por `ConsentText`, precedente `src/app/(campaign)/campanha/convite/[token]/page.tsx:49-63`) e o `removalChannelUrl`; `robots: { index:false, follow:false }`; **sem `loading.tsx`** (mesma razão do C233: `page.tsx:55-59`). +- **`src/app/(frontend)/api/fotos/selfie/route.ts`** (novo): `export const dynamic = 'force-dynamic'`; mesmo-origem (`isSameOriginRequest`, `src/utilities/sameOriginRequest.ts:33-64`); body ≤4KB pelo helper extraído; parse zod; rate limit por IP hasheado; re-checa flag + Consent no servidor; `search` → subjects elegíveis → melhor match sob o threshold → `getApprovedArchivePhotoItems()` (`archivePhotoReads.ts:58-61`) filtrado por `matchedPhotos` → view model sem nomes/score, limite 60; `leave-index` → transação (`status:'removed'`, `vector:null`, `removedAt`, `matchedPhotos:[]`). Envelopes: `{ ok:true, found:true, photos }` | `{ ok:true, found:false }` | `{ ok:true, removed:true|false }` | erros `{ ok:false, error }` (400 body, 403 origem, 429 rate limit, 404 fechado por flag/publicação, 503 Consent ausente). **Nenhuma imagem em nenhum request; nada de score; nada de nomes de terceiros.** +- **`src/app/(frontend)/fotos/page.tsx`** (editar `:119-157`): renderiza `` (link para `/fotos/encontre`) só quando `selfieSearchEnabled === true`; posição validada pelo `designer` (D9). + +**Novos — UI client** + +- **`src/components/fotos/faceSearchEngine.ts`** (novo): `computeFaceDescriptor(file)` — probes de WebGL/WASM (`cardCutout.ts:97-127`), `import('@vladmandic/face-api')` **lazy** (só quando o fluxo começa), modelos same-origin em `/fotos/modelos/`, cap ≤1024 (face maior quando há mais de um rosto); stub `NEXT_PUBLIC_FACE_SEARCH_STUB=1` no molde `cardCutout.ts:347-394` com `window.__faceSearchStub`. +- **`src/components/fotos/SelfieSearchFlow.tsx`** (novo, client): máquina de estados (consent → pick → processing → results → empty → presence/confirm → removed/no-removal; + engine/unsupported, rate-limited, network); recebe o texto do Consent já renderizado como `children` (regra de client boundary, `engineering-standards.mdc:22-25`); preview com `URL.revokeObjectURL`; a selfie nunca vai a `fetch`. +- **`src/components/fotos/SelfieSearchEntry.tsx`** e **`SelfieSearchResultCard.tsx`** (novos): entrada na `/fotos` e card do resultado **sem** “Quem aparece”, reusando `ARCHIVE_PHOTO_CARD`/botões de `archivePhotoClasses.ts`; o clique do card leva ao detalhe do C233 (`/fotos?foto=`), dono único do detalhe. + +**Novos — scripts/CLI** + +- **`scripts/copy-face-vision-assets.mjs`** (novo, molde `scripts/copy-card-vision-assets.mjs:24-47`): copia `tiny_face_detector` (~190KB) + `face_landmark_68` (~357KB) + `face_recognition` (~6,4MB) de `node_modules/@vladmandic/face-api/model` para `public/fotos/modelos/` (gitignored); idempotente por tamanho; chamado no `prebuild` (`package.json:24`), no `predev` (`:62`) e no `Dockerfile:48`. +- **`scripts/lib/faceEnrollPlan.mjs`** + **`scripts/lib/faceIndexPlan.mjs`** (novos): parsers de argv e recibos (mold `scripts/lib/archiveCatalogPlan.mjs:1-120`); pinados em `SCRIPTS_SPEC_PINNED` (`scripts/lib/test-affected-core.mjs:31-111`). +- **`scripts/enroll-face-subject.mjs`** (`pnpm faces:enroll`) e **`scripts/index-archive-faces.mjs`** (`pnpm faces:index` — sem `--subject`: o canário é `--limit`; adicionar sujeito invalida a revisão de qualquer forma) (novos): guardas de `scripts/lib/cli.mjs` (`assertWriteConfirm` `:312-320`, `assertEnvironmentDatabaseTarget` `:238-301`, `mirroredMediaRequired` `:197-202`), recibo JSON datado em `data/face/`, falha isolada e estágio nomeado (molde `scripts/catalog-archive-photos.mjs:129-280`). `package.json` ganha os dois scripts junto de `:37-38`. + +**Testes/manifest/migration/ops** + +- Testes: ver D10. Pins: entry nova em `scripts/lib/e2e-affected-manifest.mjs` (`:204-240` e `:448-460,493-552` conforme D10), curado `:24-58`, pin `tests/unit/e2eAffectedManifest.unit.spec.ts:54-89`, projeto em `playwright.config.ts:211-220` + env do stub (`:260-300`) e workflow (`ci-pr.yml:226`, `deploy.yml:200`). +- Docs: `AGENTS-public.md:3-7` (contrato `/fotos/encontre` + API + flag), runbook C234 em `docs/ops/teqo-1313-deploy.md`, `.env.example` (`NEXT_PUBLIC_FACE_SEARCH_STUB=` junto de `:36-37`), `.gitignore` (`/public/fotos/modelos/`, `/data/face/`), `docs/changelog/2026-09-27-c234.md`. + +### Dados → forma (se aplicável) + +N/A — a intenção é explícita: **não há dado numérico na superfície**. A única saída é a lista de fotos; score/percentual é anti-goal (o view model não tem campo de similaridade — o shape torna a violação impossível, não apenas proibida). + +## Decisões de engenharia + +### D1 — Engine facial (browser + CLI) + +**Opções:** A) `@vladmandic/face-api` 1.7.15 (MIT) nos dois lados — browser `dist/face-api.esm.js` (TFJS pré-bundlado) e CLI `dist/face-api.node-wasm.js` com `@tensorflow/tfjs` + `@tensorflow/tfjs-backend-wasm`; B) MediaPipe `tasks-vision`; C) `@tensorflow/tfjs-node`; D) Playwright headless; E) API de terceiro. +**Recomendação:** **A** — um único engine e **a mesma matemática** nos dois lados (descriptor 128-d, distância euclidiana, threshold estrito 0.45 interno, nunca devolvido); no browser o import é lazy e os modelos (`tiny_face_detector` + `face_landmark_68` + `face_recognition`, ~7MB) são copiados same-origin para `public/fotos/modelos/` (zero CDN, precedente `copy-card-vision-assets.mjs`); no CLI não há binário nativo (backend wasm do próprio pacote) nem browser — decode com `sharp` (já dependência) → `tf.tensor3d`. O modelo é versionado no registro (`model` no subject; string `face-api@1.7.15/faceRecognitionNet`) e trocá-lo **invalida o índice** (re-enrollment, D4/riscos). +**Rejeitadas:** **B** porque não tem embedder facial — a geometria de landmarks é fraca demais para similaridade de identidade; **C** porque o binário nativo no Node 24 é risco operacional (build/AJV) por zero ganho; **D** porque a inferência no lote via browser headless é infra frágil (e o C232 já rejeitou esse caminho); **E** porque biometria facial de cidadão não vai a terceiro (guardrail de produto). + +### D2 — Dados: `faceSubject`, marker de incremental e kill switch + +**Opções:** A) collection nova `faceSubject` + grupo hidden `faces` em `archivePhoto` + flag `selfieSearchEnabled` no global `photoAlbum`, tudo na mesma migration aditiva; B) guardar o descriptor em campos `json` do próprio `archivePhoto`; C) reusar `Contact`/`leadership` ou tabela fora do Payload. +**Recomendação:** **A** — cada subject é um registro auditável (label, consent vinculado, snapshot do hash do texto, modelo, status, `removedAt`, vínculos), com PII mínima e sem vínculo de pessoa; `matchedPhotos` e `vector` são `readOnly` no admin (só o CLI escreve, com bypass documentado + `context`); `status` indexado distingue ativo de removido sem apagar histórico; o marker `checkedAt` + `checkedKey` (sha256 de `model` + `id:updatedAt` dos subjects elegíveis ordenados) é o **único jeito honesto** de pular fotos sem reprocessar bytes a cada run (sem a key, um subject novo/atualizado geraria skip errado); a flag `selfieSearchEnabled` default `false` é o kill switch da feature — o Consent referenciado **não** pode ser deletado (`Consent.ts:8-52` estendido), então a flag é o “puxar a tomada” sem deploy. +**Rejeitadas:** **B** porque um vetor por foto não é o modelo de subjects consentidos (sem status/consent/remoção) e mistura o índice ao acervo; **C** porque `Contact` é cadastro de pessoa (invariante do repo) e uma tabela fora do Payload perde migrations/access/admin. + +### D3 — Enrollment (ops-only, CLI) + +**Opções:** A) CLI `pnpm faces:enroll --label "" --selfie --consent ` (dry-run default; `--apply`; `--subject ` para re-enrollment); B) auto-enrollment público a partir da selfie de consulta; C) tela admin de enrollment já no MVP. +**Recomendação:** **A** — a adesão é ato da assessoria **com** a pessoa (consentimento assinado, `Consent` próprio de enrollment com key estável `busca-selfie-indice`, hash snapshot no registro); o CLI exige exatamente **1 rosto** na selfie (0 ou >1 recusam, fail-closed), valida a key do consent contra a constante e grava `status active`; guardas `FACE_ENROLL_CONFIRM=1` + `assertEnvironmentDatabaseTarget` + recibo JSON sem vetor nem bytes; `--dry-run` mostra a contagem de rostos e o que seria gravado. Re-enrollment (`--subject`) é o caminho previsto para troca de modelo/atualização (a assessoria mantém as selfies fora do site — nada de imagem é guardado pelo Teqo). +**Rejeitadas:** **B** porque sem verificação de identidade vira consentimento de terceiro + DoS de opt-out (qualquer um pode remover o rosto alheio do índice) — registrar por quê; **C** adiada com gatilho (assessoria precisar operar sem CLI). + +### D4 — Indexação em lote + +**Opções:** A) CLI `pnpm faces:index [--apply|--verify] [--limit] [--refresh]` rodando na workstation, vínculos transacionais, descriptors transitórios e marker incremental; B) job in-app disparado por `after()`/fila; C) worker/browser headless. +**Recomendação:** **A** — só fotos `approved`; por foto, baixa os bytes privados (`downloadPrivateMediaToFile`), redimensiona ≤1024px com `sharp`, detecta **todos** os rostos, calcula descriptors **que nunca são persistidos** (se um subject ativo tiver distância < threshold em qualquer rosto, o vínculo nasce) e, dentro de `withPayloadTransaction` com fresh-read dos subjects, substitui `matchedPhotos` (adiciona novos vínculos e **remove o vínculo que deixou de casar**), gravando o marker; `--refresh` ignora o marker; `--limit` canário; `--verify` é read-only e reporta fotos indexadas/stale/pendentes, subjects ativos/removidos/consentimento vencido e os vínculos por subject (breakdown); recibo JSON honesto em `data/face/`; idempotente e retomável; **documentado que adicionar subject novo reprocessa todas as aprovadas** (a `checkedKey` muda). Guardas: `FACE_INDEX_CONFIRM=1`, alvo de banco declarado, mídia espelhada (S3) — mesmo combo do C232. +**Rejeitadas:** **B** porque o repo não tem fila e o lote pertence ao container de manutenção (precedente C231/C232); **C** porque browser headless para 6,5k é frágil; e **persistir descriptors anônimos** é proibido (seria B disfarçado). + +### D5 — Consulta: rota, API e contrato de resposta + +**Opções:** A) `/fotos/encontre` (RSC fail-closed) + `POST /api/fotos/selfie` com `{ vector, intent }`, matching server-side e view model cortado; B) client falando direto com a REST do Payload; C) upload da selfie e matching server-side. +**Recomendação:** **A** — a página checa `published` (404) e `selfieSearchEnabled` (404) e renderiza a cena 7 (200) sem Consent; o API re-checa tudo no servidor (fail-closed em profundidade), aceita **só o vetor** (JSON ≤4KB, sem multipart/imagem), exige same-origin, aplica rate limit, resolve o Consent de consulta e só então: `search` → subjects elegíveis (`status active` + modelo corrente + `consentHash` vigente) → melhor distância sob **0.45** → fotos **aprovadas** ∩ `matchedPhotos` → view sem `people`/`peopleLabel`/score, no máximo `FACE_SEARCH_RESULT_LIMIT = 60` (mais recentes primeiro; sem paginação no MVP); sem match **ou** sem fotos aprovadas → `{ ok:true, found:false }` (a pessoa não indexada recebe vazio honesto, nunca “parecidos”); `leave-index` → match → transação zera `vector`, marca `removed`, `removedAt` e limpa `matchedPhotos`; sem match → `{ ok:true, removed:false }` e a UI aponta para o canal de remoção do C233 (nunca inventado — `PhotoAlbum.ts:22-37,72-79`). A entrada fica **só na `/fotos`** (cena 1), sem link no header/rodapé no MVP (não anuncia fluxo fechado; posição validada pelo `designer` — D9). +**Rejeitadas:** **B** impossível/sujo (a collection é admin-only e o client enxergaria índice/metadados); **C** contraria o anti-goal “a selfie não sai do dispositivo” — é o inverso do desenho. + +### D6 — Consent: duas chaves estáveis, texto nunca seedado + +**Opções:** A) adicionar as keys em `src/lib/campaignConsentKeys.ts` (owner atual, client-safe); B) renomear o registry para módulo neutro e reexportar; C) keys hardcoded no CLI/rota. +**Recomendação:** **A** — `busca-selfie-fotos` (consulta; resolve no RSC e na API) e `busca-selfie-indice` (adesão; resolve no `faces:enroll` e vira snapshot em `consentHash`); o arquivo já é o dono dos stable keys e já hospeda key pública (`WHATSAPP_SUBSCRIPTION_CONSENT_KEY`, `src/lib/campaignConsentKeys.ts:26`), então só o comentário de cabeçalho muda; a resolução reusa `getConsentByKey`/`requireConsentByKey` (`src/utilities/campaignConsent.ts:72-111`, bypass de leitura documentado) e o texto é renderizado por `ConsentText`; **nenhum texto é seedado por migration** (nasce fechado; assessoria/jurídico criam o Consent no admin quando liberarem), e o `consentHash` vencido deixa o subject **inelegível** (fail-closed) até re-consentimento. O runbook de abertura (criar o Consent com key+texto → `faces:enroll` → `faces:index` → ligar `selfieSearchEnabled` → verificar) fecha a entrega. +**Rejeitadas:** **B** porque o rename move ~15 import sites sem nenhum ganho de comportamento (gatilho: o nome passar a mentir, ex. 3º fluxo público); **C** porque ID/key hardcoded é proibido pelo invariante. + +### D7 — Remoção e atendimento + +**Opções:** A) sair do índice self-service por match facial + canal de foto do C233; B) collection de pedidos de remoção com formulário; C) e-mail manual sem registro. +**Recomendação:** **A** — o opt-out é o próprio match (D5) e o registro do atendimento é o estado do subject (`status removed` + `removedAt`); despublicar foto continua o fluxo do C233 (`publicationStatus: removed` pela staff + `ArchivePhotoRemovalBand`/canal — `ArchivePhoto.ts:340-348`, `AGENTS-public.md:5-7`). Sem collection nova, sem PII nova, sem Consent novo para pedido. +**Rejeitadas:** **B** (formulário/collection/Consent novos, fora do appetite e do invariante); **C** (não registra e não é fail-closed). + +### D8 — Antiabuso + +**Opções:** A) reusar o dono do throttle (`contentEventRateLimit.ts`) com budget parametrizável + same-origin + body cap extraído; B) limiter novo para a feature; C) reusar as constantes atuais como estão. +**Recomendação:** **A** — `checkContentEventRateLimit(key, budget)` mantém o default do beacon (`:17-25,53-73`) e a busca passa budget estrito (`20` requisições / 10 min por IP hasheado; a busca responde a um **oráculo de pertencimento** — testar vetores para descobrir quem está no índice —, então o budget tem de ser bem menor que o de telemetria); `contentEventClientKey` (`:34-41`) continua sendo o único hash de IP (sal por processo, nada persistido); o body reader sai de `content-events/route.ts:47-78` para `boundedRequestBody.ts` e os dois routes consomem o mesmo dono; sem conta e **sem captcha no MVP** (gatilho registrado). O limiter é bar-raiser, não fronteira de segurança — o gate real é o threshold + Consent. +**Rejeitadas:** **B** (segundo limiter = twin); **C** (120/10min é frouxo demais para biometria); captcha adiado com gatilho (custo de UX/dependência de terceiro). + +### D9 — UI e extensão do design (trigger do `designer`) + +**Opções:** A) `designer` estende/adapta o artefato **antes** do markup (trigger a/b) e faz a crítica final renderizada (trigger c), com port classe-a-classe; B) portar o artefato literal; C) redesenhar. +**Recomendação:** **A** — o artefato cobre 8 cenas, mas falta o recorte de execução: (i) **cena 6 no modo A/C** — o vazio honesto precisa dizer que a busca cobre quem autorizou participar do índice (sem prometer “todas as fotos”); (ii) **resultado sem “Quem aparece”** (a cena 5 já não mostra, mas o card do C233 mostra — a variante C234 é nova, `ArchivePhotoCard.tsx:53-55`); (iii) estados novos: engine indisponível (WebGL/WASM off), erro de engine, rate-limited, erro de rede — mensagens honestas (nunca “tente outra foto” genérico, precedente `cardCutout.ts:13-20`); (iv) mapeamento de rota: a cena 2 (modal sobre o álbum) vira a página `/fotos/encontre` e a cena 1 vira a entrada na `/fotos` (posição do aside validada); (v) opt-out sem match (encaminha ao canal do C233) e confirmação da cena 8; (vi) a11y já desenhada (44px, foco visível, `prefers-reduced-motion`) mantida. Port com os contratos de classe de `archivePhotoClasses.ts` e `ConsentText`; crítica final com screenshots 390/1280; `Design tier:` no PR. +**Rejeitadas:** **B** (o artefato não cobre rota/estados novos — seguir literal é defeito); **C** (design aprovado existe; redesign é desperdício). + +### D10 — Verificação por camada (pins e e2e) + +**Opções:** A) unit puro + int das fronteiras Payload/HTTP + e2e novo com stub, no manifest/projeto/curado/pin; B) só int; C) e2e de browser para tudo. +**Recomendação:** **A** — **unit:** `tests/unit/faceSearch.unit.spec.ts` (distância/limiar/melhor match; elegibilidade por modelo+hash; view sem nomes; derivação do stub), `tests/unit/faceSearchRequest.unit.spec.ts` (parse fail-closed do body), `tests/unit/faceEnrollPlan.unit.spec.ts`/`faceIndexPlan.unit.spec.ts` (parsers/recibos), `tests/unit/faceCli.unit.spec.ts` (guardas por subprocesso, molde `archiveCatalogCli.unit.spec.ts`), extensão do unit do rate limit (budget) e das keys de consent. **int:** `tests/int/faceSubjectIndex.int.spec.ts` (enrollment grava; index com analyzer stub cria/remove vínculos; removed não casa; curadoria/aprovação intocadas; transação) e `tests/int/faceSearchApi.int.spec.ts` importando o `POST` (precedente `contentEvents.int.spec.ts:40`): sem Consent → recusa; match devolve **só aprovadas**; **sem score e sem nomes**; subject `removed` não casa; foto `approved→removed` some; opt-out zera vetor e não casa mais; rate limit; same-origin; body inválido. **e2e:** `tests/e2e/frontendFotosSelfie.e2e.spec.ts` (serial) com o engine stubado (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, seam `faceStubDescriptorFromBytes` compartilhado entre stub e spec — fixtures sem rede), seeds via REST admin (Consent novo, subject, fotos, global) e cleanup; projeto Playwright `frontendFotosSelfie` com `dependencies: ['frontendFotos']` nos **dois** modos (serializa contra o flip do kill switch do álbum; o C233 liga/desliga o global em `frontendFotos.e2e.spec.ts:335-345`); manifest: `frontendFotosSelfie` na entry do C233 (`e2e-affected-manifest.mjs:204-225`) e em `src/lib/schemas`/`src/utilities/content` (`:448-460,493-552`), entry nova com `src/app/(frontend)/api/fotos`, `src/lib/faceSearch`, `src/utilities/faceSubjects`, `src/collections/FaceSubject.ts`, `src/collections/Consent.ts`; curado `E2E_CURATED_SPECS:24-58` + pin `tests/unit/e2eAffectedManifest.unit.spec.ts:54-89` (a migration torna o PR high-risk → conjunto curado). Os scripts/planos novos entram em `SCRIPTS_SPEC_PINNED`. +**Rejeitadas:** **B** (o contrato HTTP público de biometria pede prova real); **C** (browser para lógica pura é caro e frágil). + +## Fases verificáveis + +1. **Tracer / schema+server — quota ~40%:** migration `add_face_subject` (collection + join + marker + flag) com SQL revisado + `generate:types`; keys + `src/lib/faceSearch.ts` + schema zod; reads/enrollment/index writes; `POST /api/fotos/selfie`; guard do `Consent.ts`; budget do rate limit + extração do body cap. Unit + int verdes antes de seguir. Gate: `pnpm gate:fast` + int do caminho. +2. **CLI ops — quota ~20%:** spike do engine node-wasm (`face-api.node-wasm.js` + tfjs wasm backend em Node, 1 foto de fixture) **antes** de construir o CLI; `copy-face-vision-assets.mjs` + hooks (`package.json`, `Dockerfile`), `faceEnrollPlan.mjs`/`faceIndexPlan.mjs`, `faces:enroll`/`faces:index` com guardas e recibo; unit dos parsers/guardas; `--verify`/plano contra o banco do worktree. Gate: `pnpm gate:fast`. +3. **UI/Design — quota ~30%:** dispatch do `designer` (trigger a/b) e extensão do artefato; components `SelfieSearch*` + engine + stub; `/fotos/encontre` + entrada na `/fotos`; port classe-a-classe; e2e local `--no-deps --project=frontendFotosSelfie`; crítica final (trigger c) com screenshots 390/1280. +4. **Gates/entrega — quota ~10%:** spec e2e + projeto + manifest + curado + pin; `pnpm gate:fast`, int, e2e local; `/simplify` + `capture-review-debts`; docs (`AGENTS-public.md`, runbook C234, `.env.example`, `.gitignore`, changelog); `pnpm push` → PR Ready com `Closes #1369`, `Design tier` e o status dos hard-stops. + +## Rabbit holes / Não escopo (engenharia) + +- **“Outras pessoas parecidas”, score/percentual, ranking** — vedados; o shape do view model não tem o campo. +- **Guardar selfie/descriptor do visitante** (galeria, localStorage, cookie, “melhorar o match”) — vedado; vetor só em memória do request. +- **Modo B (indexar rostos anônimos do acervo)** sem DPIA/aval — proibido pelo gate desta entrega. +- **Auto-enrollment público, UI de enrollment, fila de revisão de matches** — adiados com gatilho. +- **Vídeo/tempo real/câmeras no evento, app nativo, API pública de reconhecimento** — fora. +- **Vínculo com `Contact`/leadership, “quem é quem”, CRM de rostos** — invariante. +- **Reusar o índice semântico do C229** (falas) para rostos — dono e modalidade diferentes. +- **Segundo engine, terceiro de reconhecimento, modelo via CDN, `tfjs-node`, inferência server-side da selfie** — rejeitados em D1/D5. +- **Segundo limiter, segundo body cap, segundo mecanismo de Consent** — D8/D6 (editar o dono). +- **Global novo de configuração da busca** — a flag vive no `photoAlbum` (dono das chaves do álbum); sem terceiro global. +- **Paginação/ordenação configurável do resultado, contadores de busca, debug de vetores** — fora do MVP. + +## Riscos e mitigação + +- **Falso positivo de match (sem score exposto):** threshold estrito 0.45 + melhor match único + só fotos aprovadas + canal de remoção + opt-out; a assimetria é honesta (o pior caso mostra fotos públicas sem qualquer identificação/nome). Calibrar o threshold no canário de enrollment antes de ligar a flag em produção; gatilho de revisita se a taxa de falso vínculo aparecer em atendimento. +- **Engine node-wasm do CLI não validado:** a rota `face-api.node-wasm.js` + `@tensorflow/tfjs-backend-wasm` em Node é a aposta do lote; o spike da Fase 2 roda o descriptor sobre a fixture antes de qualquer CLI (fallback documentado: lote via browser headless, rejeitado para o MVP). Se o spike falhar, o plano para em `blocked` — nunca indexa com engine divergente do browser. +- **Troca de modelo invalida o índice:** `model` no subject + `checkedKey` no marker + `--verify` apontando stale; re-enrollment exige as selfies mantidas pela assessoria (fora do site) — registrar no runbook. +- **Engine falha em browser sem WebGL/WASM:** probes antes do download + estado honesto “seu navegador não permite…” (sem culpabilizar a selfie); sem fallback silencioso (precedente `cardCutout.ts:13-20,97-127`). +- **~9MB de bundle/modelos:** import dinâmico só quando o fluxo começa + modelos same-origin baixados com progresso; nunca no bundle inicial da `/fotos`. +- **Lote longo (6,5k fotos, workstation):** `--limit` canário, falha isolada por foto com estágio nomeado, retomada pela marker, recibo com tempo real; rodar fora da janela de deploy. +- **Índice vazio no MVP A/C:** o vazio honesto (cena 6 com recorte A/C) evita promessa falsa; o runbook exige enroll+index antes de ligar a flag; `hasPublishedArchivePhotos` continua dono do link “Fotos”. +- **Oráculo de pertencimento (abuso):** rate limit estrito por IP hasheado + threshold + resposta sem ids/scores; nada lista subjects publicamente. +- **PR high-risk (migration + lockfile):** curado de e2e com `frontendFotosSelfie`/`frontendFotos` + pin atualizado no mesmo PR; e2e local `--no-deps` antes do push. +- **Corrida de e2e com o kill switch do álbum:** projeto com `dependencies: ['frontendFotos']` nos dois modos + linhas próprias + restauração de global/Consent no `afterAll`. +- **Hook de revalidação do `archivePhoto` disparando no CLI:** `revalidateTagSafely` engole fora de request (`documents.ts:27-34`) e o `context: { faceIndex: true }` mantém a curadoria intocada (`ArchivePhoto.ts:168`). +- **Consent apagado por baixo dos subjects:** guard de `beforeDelete` estendido (`Consent.ts:24-90`); texto editado deixa subjects inelegíveis até re-consentimento (fail-closed). +- **Boot com S3 parcial:** mesmo dono de mídia privada; o CLI de index exige mídia espelhada no alvo (`mirroredMediaRequired`). + +## Hard-stops (aprovação humana explícita — valem mesmo em `--auto`) + +1. **Schema/migration:** `faceSubject` (+ join `matchedPhotos`), grupo hidden `faces` em `archivePhoto` e flag `selfieSearchEnabled` no global `photoAlbum` na migration aditiva `add_face_subject` — `pnpm migrate:create` → SQL revisado à mão (nenhum objeto existente recriado) → `pnpm migrate` **só** no banco do worktree (`teqo_wt*`) → `pnpm generate:types`; migrations entregues intocadas. **✅ Aprovado na sessão `--auto` (2026-09-27).** +2. **Contrato público/URL e shapes:** `/fotos/encontre` (noindex; 404 sem flag/publicado, 200 com o estado fechado sem Consent), `POST /api/fotos/selfie` com body `{ vector: number[128], intent: 'search'|'leave-index' }` (JSON ≤4KB; sem imagem) e envelopes `{ ok, found, photos }`/`{ ok, removed }` sem score/nomes, com 404 (fechado) e 503 (Consent ausente) como recusas; entrada na `/fotos` gated pela flag. **✅ Aprovado na sessão `--auto` (2026-09-27).** +3. **Consent/DPIA/chaves e política de nascer fechado:** keys `busca-selfie-fotos` e `busca-selfie-indice`, **texto não seedado**, flag default `false`, índice A/C (só consentidos; rostos anônimos proibidos) e **aval jurídico + DPIA antes de ligar em produção** (timing eleitoral, eleição 04/10/2026); o runbook de abertura documenta o ato. **✅ Aprovado na sessão `--auto` (2026-09-27).** +4. **Dependência nova de engine + assets (disclose):** `@vladmandic/face-api@1.7.15` (MIT) + `@tensorflow/tfjs` + `@tensorflow/tfjs-backend-wasm` no `package.json`/lockfile (lockfile = high-risk), com ~9MB de modelos gitignored em `public/fotos/modelos/` servidos same-origin (sem CDN, sem terceiro em runtime); `copy-face-vision-assets.mjs` no `prebuild`/`predev`/Dockerfile. **✅ Aprovado na sessão `--auto` (2026-09-27).** +5. **Enrollment ops-only vs UI:** enrollment e indexação só por CLI (`faces:enroll`/`faces:index`); auto-enrollment público e UI de admin **fora do MVP** — auto-enrollment rejeitado por ausência de verificação de identidade (consentimento de terceiro + DoS de opt-out); UI adiada com gatilho. **✅ Aprovado na sessão `--auto` (2026-09-27).** + +## Adiado com gatilho + +- **Refatoração do `SelfieSearchFlow`** — 886 linhas/15 estados com render inline; `LeaveConfirmDialog` reimplementa o chassi do kit (`ui/dialog`, Radix); intents/envelopes redigitados em vez de derivar de `lib/faceSearch`/rota. **Gatilho:** próximo toque de comportamento no fluxo (novo estado/envelope), 2º diálogo nas fotos ou requisito de a11y em modal. +- **2ª cópia do copiador de assets de visão** — `copy-face-vision-assets.mjs` quase verbatim de `copy-card-vision-assets.mjs`. **Gatilho:** 3ª cópia `copy-*-vision-assets` — extrair o loop `stat`+size+write para `scripts/lib/`. +- **Recibos/`reportStamp` por CLI de lote** — 3ª cópia (`archiveCatalogPlan`/C232 + `faceEnrollPlan` + `faceIndexPlan`) com `writeReport` quase verbatim nos scripts. **Gatilho:** próximo CLI de lote/recibo — extrair `reportStamp`+`writeReport` para `scripts/lib/cli.mjs` junto do `writeRepoFile`. +- **Reader nomeado do global `photoAlbum`** — residual (comentário de leitura pública já no ponto da rota). **Gatilho:** 3º consumidor direto do global (hoje rota `/api/fotos/selfie` + 2 RSCs via `getCachedGlobal`). +- **Workspace único por run no `faces:index`** — hoje `mkdtemp`/`rm` por foto; mesmo gatilho do bullet de índice incremental (tempo do lote acima do canário ou aprovadas > ~1k). +- **UI de enrollment no admin** — gatilho: assessoria precisar operar sem CLI (volume de adesões ou equipe sem terminal). +- **Captcha/anti-bot** — gatilho: padrão de abuso/comportamento de oráculo observado (contadores, não conteúdo). +- **Modo B (índice de rostos do acervo com DPIA)** — gatilho: DPIA + aval jurídico documentados. +- **Re-embedding automático em troca de modelo** — gatilho: troca do `FACE_SEARCH_MODEL` (vira o item de runbook de migração de índice). +- **Índice incremental otimizado / sharding por município** — gatilho: tempo do lote materialmente acima do canário ou aprovadas > ~1k. +- **Revisão/auditoria de matches pela assessoria (admin)** — gatilho: primeira queixa de falso vínculo ou pedido do jurídico. +- **Extração do registry de consent keys para módulo neutro** — gatilho: 3º fluxo público de consentimento ou o nome passar a mentir. +- **Extração do rate limit/body cap para módulo neutro** — gatilho: 3ª superfície anônima consumindo o mesmo dono. +- **Upload consentido da selfie com retenção zero** — gatilho: cobertura on-device provar-se insuficiente para o público-alvo **e** jurídico aprovar o caminho (hoje: rejeitado). +- **Paginação/filtros do resultado** — gatilho: pessoas com mais de 60 fotos aprovadas no índice. + +## Fechamento (sessão `--auto`, 2026-09-27) + +- **Hard-stops:** os cinco aprovados explicitamente pelo humano na sessão (schema/migration, contrato público/URL e shapes, Consent/DPIA/chaves com texto não seedado, dependência nova de engine + assets, enrollment ops-only). +- **Desvios registrados na execução:** (1) `faces:index` ficou **sem `--subject`** (canário = `--limit`; qualquer enrollment invalida a revisão de todas as fotos, então o filtro por sujeito economizava pouco e adicionava estado) e o `--verify` ganhou o breakdown de vínculos por sujeito; (2) a recusa do endpoint usa **404** para flag/publicação fechadas e **503** para Consent ausente; (3) o rate limit ganhou **namespace de chave** (`selfie:`) sobre o dono compartilhado — o beacon e a busca não consomem mais o mesmo contador; (4) o miolo do card virou `ArchivePhotoCardBody` (dono C233) e a variante C234 só troca o href e omite a linha de terceiros; (5) `enrolled_at` (não `updated_at`) é a revisão do sujeito na `checkedKey` — o lote escreve `matchedPhotos` e avançaria o `updated_at` para sempre. +- **Design (trigger c): PARIDADE CERTIFICADA** pelo `designer` (tier primário `openai/gpt-5.6-sol`, sem `DEGRADED`) em 3 rodadas contra o app renderizado (390/1280): faixa de entrada, consentimento, escolha, processamento, resultado (grade larga, superfície branca, título 34px), presença, diálogo, vazio e erro de engine. Ajustes da crítica aplicados: `border-0 pb-0` nos títulos (o `h2` global injeta borda), resultado full-bleed branco com a faixa creme de presença contrastando, scroll/foco no início de cada passo, submit do álbum secundário com a faixa ativa, 44px no "Voltar ao álbum" e no "Voltar sem alterar", copies mobile por breakpoint, ícones semânticos (imagem/aparelho/lixeira), `aria-label` distinto no backdrop do diálogo. +- **`/simplify` + débitos:** dois revisores (estrutural + qualidade) rodados no diff; fixes aplicados (namespace do rate limit, card body compartilhado, foco/scroll por passo, projeção enxuta do subject, dedup de fixtures int, uso do dono `waitForStreamSettled` no e2e novo, remoção de código morto). Triagem `capture-review-debts`: zero `expensive_lock` ≥4 → nenhuma Issue nova; baratos deferidos na seção "Adiado com gatilho" abaixo. + +## Aceite de engenharia + +- [ ] Aceite de produto coberto: com Consent + flag + adesão, o visitante recebe só as fotos aprovadas em que aparece (sem terceiros/score); sem Consent (ou sem flag) recusa honesta na UI e no servidor; selfie nunca sobe; remoção/opt-out efetivos e registrados; antiabuso ativo; A/C estrito (rostos anônimos fora). +- [ ] Invariantes AGENTS/engineering-standards: sem cadastro paralelo/PII desnecessária; Consent por key estável fail-closed; escrita multi-collection em transação com `req`; bypasses documentados (`overrideAccess` no CLI/na rota); `import 'server-only'`; identificadores em inglês e copy pt-BR; editar o dono (rate limit, body cap, guard do Consent), sem twins. +- [ ] Migration aditiva gerada, revista à mão, aplicada local e registrada; `pnpm generate:types`; nenhuma migration retroeditada. +- [ ] Testes de domínio previstos (unit/int) onde o contrato/shape/transações mudam; e2e novo no disco + projeto Playwright + `E2E_AFFECTED_MANIFEST` + `E2E_CURATED_SPECS` + pin + `SCRIPTS_SPEC_PINNED`. +- [ ] UI portada classe-a-classe do artefato estendido pelo `designer` (triggers a/b), crítica final (c) com screenshots 390/1280 e `Design tier:` no PR. +- [ ] `pnpm gate:fast` verde; int do caminho verde; e2e `frontendFotosSelfie`/`frontendFotos` verde local; runbook + `AGENTS-public.md` + `.env.example` + `.gitignore` + changelog no fechamento; hard-stops com aprovação explícita antes do push. + +## Self-score (decision-quality) + +**Self-score decision-quality: 4/5.** (1) todas as decisões caras (engine, schema/marker/kill switch, enrollment ops, lote, contrato de consulta, consent, remoção, antiabuso, UI, verificação) têm Opções + Recomendação + Rejeitadas; (2) cabe no appetite: uma collection nova, uma migration aditiva, uma rota pública nova, reuso dos donos existentes (approval gate/media privada/transação/consent/guardas de CLI) e o lote é máquina, não código; (3) rabbit holes nomeados (parecidos/score, retenção de selfie, modo B, auto-enrollment, segundo limiter/engine, C229, CRM de rostos); (4) depth check: edita donos reais (`contentEventRateLimit`, body cap do beacon, guard do `Consent`, global `photoAlbum`, hook de revalidação do `ArchivePhoto`) e só cria módulos onde não há dono (`faceSubjects/`, `lib/faceSearch`, rotas); (5) outcome preservado — fail-closed, A/C, sem PII desnecessária, sem score, sem reescrever o aceite. O que impede o 5: o **threshold 0.45** e a qualidade do engine são aposta não medida (calibração só no canário), e a **operação de re-enrollment na troca de modelo** depende das selfies mantidas pela assessoria fora do site — ambos registrados como risco com gatilho, não escondidos. diff --git a/docs/plans/busca-fotos-por-selfie-ui-design.html b/docs/plans/busca-fotos-por-selfie-ui-design.html index 66431751..d2d9db47 100644 --- a/docs/plans/busca-fotos-por-selfie-ui-design.html +++ b/docs/plans/busca-fotos-por-selfie-ui-design.html @@ -60,7 +60,7 @@ color: white; font-size: 14px; font-weight: 800; - box-shadow: 0 8px 18px rgb(228 16 47 / 18%); + box-shadow: 0 4px 0 #7f1616; } .secondary { display: inline-flex; @@ -76,6 +76,22 @@ font-size: 14px; font-weight: 800; } + .primary:focus-visible, + .secondary:focus-visible { + outline: 3px solid var(--brand-yellow); + outline-offset: 3px; + } + .revision-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + background: #eef4fb; + padding: 4px 9px; + color: #184e92; + font-size: 11px; + font-weight: 800; + } .icon { width: 18px; height: 18px; @@ -189,6 +205,20 @@ + + + + + + + + + +
@@ -198,6 +228,9 @@ >C234 · gate hi-fiFluxo sensível · LGPD fail-closedEstendido para a execução · 27 set 2026

@@ -207,14 +240,35 @@ 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.

+
+

Deltas de execução desta revisão

+

+ A entrada agora respeita o hero e a grade reais do C233; o fluxo é uma página própria em + /fotos/encontre; 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. +

+
+
-

Cena 01

+
+

Cena 01

+ Revisada · C234 execução +

- Desktop ~1280 · entrada no contexto da /fotos + Entrada real na /fotos · flag ligada

+

+ O hero do C233 permanece inteiro. A entrada ocupa uma faixa própria entre o hero e as + facetas: descoberta forte, sem competir com o título do álbum nem transformar o hero em + duas colunas. +

@@ -226,59 +280,164 @@ class="absolute top-[-29.5px] left-[-22.4px] h-auto w-[189px] max-w-none" />
- Fotos +
-
- -
-
-

- Álbum público +

+
+
+

+ Memória da nossa caminhada

-

- Procure uma foto por data, lugar ou atividade. +

+ Fotos que contam onde a gente esteve.

-

- Use os filtros para navegar pelos registros aprovados da caminhada. +

+ Procure uma agenda, um município ou uma pessoa pública e abra cada registro com + data e contexto.

+
+
+
+
+ + Município + Atividade + Pessoa pública +
-
-
+
+

+ + +
+
+

Mobile 390 · flag ligada

+
+
+ Jorge SollaFotos +
+
+

+ Nossa caminhada +

+

+ Encontre um momento. +

+

+ Busque pela agenda, pelo lugar ou por quem apareceu. +

+
+
+ +
+ Buscar no álbum… +
+
+
+
+
+
+
+
+
+

Flag desligada · álbum normal, sem reserva vazia

+
+
+ Regra visual: a faixa inteira não existe. Hero e + facetas se encostam pelo espaçamento normal do C233; não aparece placeholder, aviso + de indisponibilidade nem link no header/rodapé. +
+
+ Buscar no álbum… +
+
+ DataMunicípioAtividade +
@@ -286,72 +445,101 @@
-

Cena 02

+
+

Cena 02

+ Revisada · página /fotos/encontre +

- Desktop ~1280 · aviso de biometria e consentimento explícito + Desktop ~1280 · aviso e consentimento na página própria, não em modal

-
-
-
-
- +
+
+
+ - -
-

- Antes da sua selfie -

-

Sua face é um dado biométrico sensível

-

- Com sua autorização, comparamos a sua selfie com o índice permitido do álbum para - mostrar somente fotos públicas aprovadas em que você aparece. -

-
-
- -

- A selfie não sai do dispositivo.
A imagem é processada aqui e não é guardada na galeria do site. +

+

+ Encontre você nas fotos +

+
+
+
-

-
-
- -

- Você pode desistir e pedir remoção.
O resultado não mostra nomes de terceiros nem percentual de semelhança. +

+ Passo 1 de 3 +
+

+ Antes da sua selfie

+

+ Sua face é um dado biométrico sensível +

+

+ 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. +

+
+
+ +

+ A selfie não sai do dispositivo.
A imagem é processada aqui e não é guardada na galeria do site. +

+
+
+ +

+ Você pode desistir e pedir remoção.
O resultado não mostra nomes de terceiros nem percentual de + semelhança. +

+
+
+ +

+ Saiba como tratamos seus dados na + Política de Privacidade. +

+ + Agora não · voltar ao álbum
- -

- Saiba como tratamos seus dados na - Política de Privacidade. -

- -
@@ -414,9 +602,12 @@
-

Cena 04

+
+

Cena 04

+ Confirmada · página, não modal +

- Mobile ~390 · processando sem promessas ou percentuais + Mobile 390 · processando em /fotos/encontre, sem promessas ou percentuais

-

Procurando você nas fotos aprovadas…

+

+ Passo 3 de 3 +

+

Procurando você nas fotos aprovadas…

Mantenha esta tela aberta. Sua selfie continua somente neste aparelho.

@@ -450,8 +644,13 @@
+
-

Cena 05

+
+

Cena 05

+ Revisada · variante sem terceiros +

Desktop ~1280 · resultado mínimo: só fotos aprovadas em que você aparece

@@ -487,33 +686,50 @@ +
+ Contrato visual da variante: imagem 4:3, título e contexto do card C233. A + linha “Quem aparece” não é reservada, ocultada nem substituída; ela não existe no + resultado por selfie. +

Quer controlar sua presença no álbum?

@@ -530,11 +746,61 @@
+
+

+ Mobile 390 · a mesma variante em grade de duas colunas +

+
+
+ Resultado +
+
+

Encontramos você nestas fotos.

+

+ Só registros públicos aprovados, sem nomes de terceiros. +

+ + +
+
+
-

Cena 06

+
+

Cena 06

+ Revisada · índice A/C +

Mobile ~390 · vazio honesto, sem “parecidos”

@@ -550,13 +816,13 @@
-

Não encontramos você

+

Não encontramos fotos suas

- Talvez a luz ou o ângulo tenham dificultado a busca — ou ainda não haja uma foto - pública aprovada sua no álbum. + Esta busca funciona para quem autorizou participar do índice. Também pode não haver + uma foto sua entre os registros públicos aprovados.

- Não mostramos pessoas parecidas para preencher a tela. + Não mostramos pessoas parecidas nem tentamos adivinhar quem você é.

-

Cena 08

+
+

Cena 08

+ Revisada · opt-out self-service +

Mobile ~390 · opt-out e pedido de remoção com confirmação

@@ -618,8 +887,8 @@

Você decide se quer aparecer

- Escolha o pedido. A equipe registra o atendimento e mantém a proteção fechada até - concluir. + 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.

- + >
Cancelar

Confirmar saída do índice?

- Vamos registrar seu pedido. Até o atendimento ser concluído, sua presença permanece - protegida e não deve voltar ao índice. + Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua + presença do índice de busca.

+ + +
+
+

Cena 09

+ Nova · engine indisponível +
+

+ Navegador sem os recursos necessários · mobile 390 +

+
+
+
+ Encontre você +
+
+
+ +
+

+ Este navegador não consegue fazer a busca por selfie +

+

+ Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este + navegador não oferece os recursos necessários. +

+

+ O problema não é com a sua selfie. Nenhuma imagem foi enviada. +

+ Voltar ao álbum +

+ Você pode tentar novamente em outro navegador ou aparelho compatível. +

+
+
+
+

Desktop 1280 · mesma copy, hierarquia adaptada

+
+
+ Jorge SollaVoltar ao álbum +
+
+
+ +
+

+ Este navegador não consegue fazer a busca por selfie +

+

+ Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este + navegador não oferece os recursos necessários. +

+ Voltar ao álbum +
+
+
+
+
+ + +
+
+

Cena 10

+ Nova · erro do engine +
+

+ Processamento local interrompido · mobile 390 +

+
+
+ Encontre você +
+
+
+ +
+

Não foi possível processar a busca

+

+ O reconhecimento neste aparelho parou antes de concluir. Sua selfie não foi enviada + nem guardada. +

+ Voltar ao álbum +
+
+
+ + +
+
+

Cena 11

+ Nova · limite de tentativas +
+

+ Resposta 429 sem linguagem técnica · mobile 390 +

+
+
+ Encontre você +
+
+
+ +
+

Muitas tentativas em pouco tempo

+

+ Para proteger este recurso, a busca foi pausada por alguns minutos. +

+

Espere alguns minutos antes de tentar novamente.

+ Voltar ao álbum +
+
+
+ + +
+
+

Cena 12

+ Nova · falha de rede +
+

+ Falha de conexão depois do processamento local · mobile 390 +

+
+
+ Encontre você +
+
+
+ +
+

Não foi possível enviar a busca

+

+ A conexão falhou ao enviar somente os dados necessários para procurar as fotos. Sua + selfie continua neste aparelho. +

+ Voltar ao álbum +
+
+
+ + +
+
+

Cena 13

+ Nova · opt-out sem match +
+

+ Minha presença · pessoa não encontrada no índice · mobile 390 +

+
+
+ Minha presença +
+
+
+ +
+

Não encontramos sua presença no índice

+

+ Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma + alteração foi feita. +

+ +
+

+ Quer retirar uma foto pública mesmo assim? +

+ Pedir remoção de uma foto +

+ Este botão usa o canal configurado no álbum; o artefato não inventa endereço. +

+
+ Voltar ao álbum +
+
+
diff --git a/package.json b/package.json index bef95c2c..ed402402 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "license": "UNLICENSED", "type": "module", "scripts": { - "prebuild": "node scripts/copy-card-vision-assets.mjs", + "prebuild": "node scripts/copy-card-vision-assets.mjs && node scripts/copy-face-vision-assets.mjs", "build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build", "db:start": "node scripts/db-start.mjs", "db:stop": "docker compose -p teqo down", @@ -36,6 +36,8 @@ "reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs", "flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.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", "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", @@ -124,8 +126,11 @@ "@payloadcms/ui": "3.82.0", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "@tensorflow/tfjs": "4.22.0", + "@tensorflow/tfjs-backend-wasm": "4.22.0", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", + "@vladmandic/face-api": "1.7.15", "ai": "^7.0.47", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/playwright.config.ts b/playwright.config.ts index 0e5fa4df..072f9da8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -218,6 +218,17 @@ export default defineConfig({ dependencies: isProdMode ? [] : ['frontend'], use: { ...devices['Desktop Chrome'], channel: 'chromium' }, }, + { + // C234 — the selfie search page (`/fotos/encontre`) and its anonymous + // endpoint. Owns its faceSubject/Consent rows and flips the SAME + // `photoAlbum` global as the C233 spec, so it serializes behind it in + // BOTH modes (the admin project precedent: a shared global cannot run + // concurrently across projects). + name: 'frontendFotosSelfie', + testMatch: /frontendFotosSelfie\.e2e\.spec\.ts/, + dependencies: ['frontendFotos'], + use: { ...devices['Desktop Chrome'], channel: 'chromium' }, + }, { name: 'admin', testMatch: /admin\.e2e\.spec\.ts/, @@ -267,6 +278,13 @@ export default defineConfig({ * the same flag in its "Build for e2e" step. */ NEXT_PUBLIC_CARDS_CUTOUT_STUB: process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB ?? '1', + /* + * C234 — the selfie-search engine (face-api + ~7 MB of models) is + * replaced by a deterministic descriptor in tests: no model download, + * no inference. The real engine is exercised by the CLI canary and + * manually (same contract as S15). + */ + NEXT_PUBLIC_FACE_SEARCH_STUB: process.env.NEXT_PUBLIC_FACE_SEARCH_STUB ?? '1', /* * C122 — the fake service-account key lets the agenda Google mirror * derive real states (synced/disabled/paused) in the server process. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8375cbe..3abe4ce4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,12 +68,21 @@ importers: '@simplewebauthn/server': specifier: ^13.3.2 version: 13.3.2 + '@tensorflow/tfjs': + specifier: 4.22.0 + version: 4.22.0(seedrandom@3.0.5) + '@tensorflow/tfjs-backend-wasm': + specifier: 4.22.0 + version: 4.22.0(@tensorflow/tfjs-core@4.22.0) '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4) '@vercel/speed-insights': specifier: ^1.3.1 version: 1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4) + '@vladmandic/face-api': + specifier: 1.7.15 + version: 1.7.15 ai: specifier: ^7.0.47 version: 7.0.47(zod@4.3.6) @@ -3306,6 +3315,47 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tensorflow/tfjs-backend-cpu@4.22.0': + resolution: {integrity: sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==} + engines: {yarn: '>= 1.3.2'} + peerDependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs-backend-wasm@4.22.0': + resolution: {integrity: sha512-/IYhReRIp4jg/wYW0OwbbJZG8ON87mbz0PgkiP3CdcACRSvUN0h8rvC0O3YcDtkTQtFWF/tcXq/KlVDyV49wmA==} + peerDependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs-backend-webgl@4.22.0': + resolution: {integrity: sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==} + engines: {yarn: '>= 1.3.2'} + peerDependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs-converter@4.22.0': + resolution: {integrity: sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==} + peerDependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs-core@4.22.0': + resolution: {integrity: sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==} + engines: {yarn: '>= 1.3.2'} + + '@tensorflow/tfjs-data@4.22.0': + resolution: {integrity: sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==} + peerDependencies: + '@tensorflow/tfjs-core': 4.22.0 + seedrandom: ^3.0.5 + + '@tensorflow/tfjs-layers@4.22.0': + resolution: {integrity: sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==} + peerDependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs@4.22.0': + resolution: {integrity: sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==} + hasBin: true + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3387,6 +3437,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/emscripten@0.0.34': + resolution: {integrity: sha512-QSb9ojDincskc+uKMI0KXp8e1NALFINCrMlp8VGKGcTSxeEyRTTKyjWw75NYrCZHUsVEEEpr1tYHpbtaC++/sQ==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -3414,6 +3467,9 @@ packages: '@types/lodash@4.17.23': resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3423,12 +3479,21 @@ packages: '@types/ndarray@1.0.14': resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@20.3.3': resolution: {integrity: sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==} '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} + '@types/offscreencanvas@2019.3.0': + resolution: {integrity: sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -3448,6 +3513,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/seedrandom@2.4.34': + resolution: {integrity: sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==} + '@types/shpjs@3.4.7': resolution: {integrity: sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==} @@ -3897,6 +3965,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vladmandic/face-api@1.7.15': + resolution: {integrity: sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw==} + engines: {node: '>=14.0.0'} + '@vue/compiler-core@3.5.40': resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} @@ -3912,6 +3984,9 @@ packages: '@vue/shared@3.5.40': resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + '@webgpu/types@0.1.38': + resolution: {integrity: sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==} + '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} @@ -3998,6 +4073,9 @@ packages: app-module-path@2.2.0: resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4073,6 +4151,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -4269,6 +4350,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -4311,6 +4395,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4368,6 +4456,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js@3.29.1: + resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -4540,6 +4631,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5158,6 +5253,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -5358,6 +5457,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -5954,6 +6057,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6181,10 +6287,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -6312,6 +6426,15 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-fetch@2.6.13: + resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6948,6 +7071,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -7101,6 +7227,9 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -7235,6 +7364,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssf@0.11.2: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} @@ -7512,6 +7644,9 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -7876,6 +8011,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -7892,6 +8030,9 @@ packages: resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -8010,10 +8151,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -11180,6 +11329,73 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tensorflow/tfjs-backend-cpu@4.22.0(@tensorflow/tfjs-core@4.22.0)': + dependencies: + '@tensorflow/tfjs-core': 4.22.0 + '@types/seedrandom': 2.4.34 + seedrandom: 3.0.5 + + '@tensorflow/tfjs-backend-wasm@4.22.0(@tensorflow/tfjs-core@4.22.0)': + dependencies: + '@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0) + '@tensorflow/tfjs-core': 4.22.0 + '@types/emscripten': 0.0.34 + + '@tensorflow/tfjs-backend-webgl@4.22.0(@tensorflow/tfjs-core@4.22.0)': + dependencies: + '@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0) + '@tensorflow/tfjs-core': 4.22.0 + '@types/offscreencanvas': 2019.3.0 + '@types/seedrandom': 2.4.34 + seedrandom: 3.0.5 + + '@tensorflow/tfjs-converter@4.22.0(@tensorflow/tfjs-core@4.22.0)': + dependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs-core@4.22.0': + dependencies: + '@types/long': 4.0.2 + '@types/offscreencanvas': 2019.7.3 + '@types/seedrandom': 2.4.34 + '@webgpu/types': 0.1.38 + long: 4.0.0 + node-fetch: 2.6.13 + seedrandom: 3.0.5 + transitivePeerDependencies: + - encoding + + '@tensorflow/tfjs-data@4.22.0(@tensorflow/tfjs-core@4.22.0)(seedrandom@3.0.5)': + dependencies: + '@tensorflow/tfjs-core': 4.22.0 + '@types/node-fetch': 2.6.13 + node-fetch: 2.6.13 + seedrandom: 3.0.5 + string_decoder: 1.3.0 + transitivePeerDependencies: + - encoding + + '@tensorflow/tfjs-layers@4.22.0(@tensorflow/tfjs-core@4.22.0)': + dependencies: + '@tensorflow/tfjs-core': 4.22.0 + + '@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)': + dependencies: + '@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0) + '@tensorflow/tfjs-backend-webgl': 4.22.0(@tensorflow/tfjs-core@4.22.0) + '@tensorflow/tfjs-converter': 4.22.0(@tensorflow/tfjs-core@4.22.0) + '@tensorflow/tfjs-core': 4.22.0 + '@tensorflow/tfjs-data': 4.22.0(@tensorflow/tfjs-core@4.22.0)(seedrandom@3.0.5) + '@tensorflow/tfjs-layers': 4.22.0(@tensorflow/tfjs-core@4.22.0) + argparse: 1.0.10 + chalk: 4.1.2 + core-js: 3.29.1 + regenerator-runtime: 0.13.11 + yargs: 16.2.2 + transitivePeerDependencies: + - encoding + - seedrandom + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -11283,6 +11499,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/emscripten@0.0.34': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -11307,6 +11525,8 @@ snapshots: '@types/lodash@4.17.23': {} + '@types/long@4.0.2': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -11315,12 +11535,21 @@ snapshots: '@types/ndarray@1.0.14': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.19.9 + form-data: 4.0.6 + '@types/node@20.3.3': {} '@types/node@22.19.9': dependencies: undici-types: 6.21.0 + '@types/offscreencanvas@2019.3.0': {} + + '@types/offscreencanvas@2019.7.3': {} + '@types/parse-json@4.0.2': {} '@types/pg@8.10.2': @@ -11341,6 +11570,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/seedrandom@2.4.34': {} + '@types/shpjs@3.4.7': dependencies: '@types/geojson': 7946.0.16 @@ -11721,6 +11952,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@vladmandic/face-api@1.7.15': {} + '@vue/compiler-core@3.5.40': dependencies: '@babel/parser': 7.29.7 @@ -11753,6 +11986,8 @@ snapshots: '@vue/shared@3.5.40': {} + '@webgpu/types@0.1.38': {} + '@workflow/serde@4.1.0': {} accepts@2.0.0: @@ -11832,6 +12067,10 @@ snapshots: app-module-path@2.2.0: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -11942,6 +12181,8 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} available-typed-arrays@1.0.7: @@ -12134,6 +12375,12 @@ snapshots: client-only@0.0.1: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -12178,6 +12425,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -12212,6 +12463,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.29.1: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -12364,6 +12617,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dependency-tree@11.5.0: @@ -13178,6 +13433,14 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -13371,6 +13634,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -13923,6 +14190,8 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + long@4.0.0: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -14387,8 +14656,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -14515,6 +14790,10 @@ snapshots: node-domexception@1.0.0: {} + node-fetch@2.6.13: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -15324,6 +15603,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -15531,6 +15812,8 @@ snapshots: secure-json-parse@4.1.0: {} + seedrandom@3.0.5: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -15777,6 +16060,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + ssf@0.11.2: dependencies: frac: 1.1.2 @@ -16056,6 +16341,8 @@ snapshots: dependencies: tldts: 7.0.23 + tr46@0.0.3: {} + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -16440,6 +16727,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@3.0.0: {} @@ -16454,6 +16743,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -16572,8 +16866,20 @@ snapshots: yaml@2.9.0: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/scripts/copy-face-vision-assets.mjs b/scripts/copy-face-vision-assets.mjs new file mode 100644 index 00000000..c5cc0e5f --- /dev/null +++ b/scripts/copy-face-vision-assets.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * C234 — copies the `@vladmandic/face-api` model weights into + * `public/fotos/modelos/` so the selfie search runs 100% same-origin (no CDN + * executing in the visitor's browser; the e2e suite never touches the network). + * + * Same contract as `copy-card-vision-assets.mjs` (S15): the models are a + * gitignored build artifact — this script runs in `pnpm dev` (preflight), in + * `pnpm build` (npm `prebuild`) and in the Docker builder before `next build`. + * Idempotent and cheap: a file already present with the same byte size is + * skipped. The list must match the nets `faceSearchEngine.ts` (browser) and + * `scripts/lib/faceEngine.mjs` (CLI) load. + */ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +export const FACE_MODEL_FILES = [ + 'tiny_face_detector_model-weights_manifest.json', + 'tiny_face_detector_model.bin', + 'face_landmark_68_model-weights_manifest.json', + 'face_landmark_68_model.bin', + 'face_recognition_model-weights_manifest.json', + 'face_recognition_model.bin', +] + +export const copyFaceVisionAssets = async (repoRoot) => { + const sourceDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model') + const targetDir = path.join(repoRoot, 'public/fotos/modelos') + await mkdir(targetDir, { recursive: true }) + + for (const name of FACE_MODEL_FILES) { + const source = path.join(sourceDir, name) + const target = path.join(targetDir, name) + + try { + const [sourceStat, targetStat] = await Promise.all([ + stat(source), + stat(target).catch(() => null), + ]) + if (targetStat && targetStat.size === sourceStat.size) continue + await writeFile(target, await readFile(source)) + console.log(`[copy-face-vision-assets] ${name}`) + } catch (error) { + throw new Error( + `[copy-face-vision-assets] failed to copy ${name}: ${error instanceof Error ? error.message : error}`, + ) + } + } +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isDirectRun) { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + await copyFaceVisionAssets(repoRoot) +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 20e19bc5..c180968d 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -15,6 +15,7 @@ import { spawn } from 'node:child_process' import { createRequire } from 'node:module' import { copyCardVisionAssets } from './copy-card-vision-assets.mjs' +import { copyFaceVisionAssets } from './copy-face-vision-assets.mjs' import './guard-dev-db.mjs' import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs' @@ -27,6 +28,7 @@ loadCliEnv() // server serves it same-origin before Next boots. try { await copyCardVisionAssets(process.cwd()) + await copyFaceVisionAssets(process.cwd()) } catch (error) { die(error.message) } diff --git a/scripts/enroll-face-subject.mjs b/scripts/enroll-face-subject.mjs new file mode 100644 index 00000000..fdad1416 --- /dev/null +++ b/scripts/enroll-face-subject.mjs @@ -0,0 +1,224 @@ +/** + * C234 — enrolls one person in the selfie-search index (A/C scope of the gate, + * PR #1370): the assessoria runs this WITH the person, after the signed + * adherence consent, and the row is the only place a consented descriptor + * exists. The selfie is read from disk, the descriptor is computed locally by + * the same face-api nets the browser runs (only ONE face is accepted) and the + * image is never stored or uploaded anywhere. + * + * Modes: + * pnpm faces:enroll --label "" --selfie + * plan/dry-run (default) — detects the face, + * resolves the consent and reports; no write + * pnpm faces:enroll ... --apply writes the faceSubject row; requires + * FACE_ENROLL_CONFIRM=1 outside local dev and + * the declared TEQO_ENV matching the database + * + * Options: --subject (re-enrollment: replaces the descriptor, advances + * `enrolledAt` and clears the derived links), --out (receipts, default + * `data/face`), --help. + * + * Guards: fails closed when the index Consent (stable key) is not configured, + * when the selfie has 0 or more than 1 detectable face, and on a non-local + * target without the intent flag + declared environment. The receipt never + * carries the descriptor nor the image. + */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { getPayload } from 'payload' + +import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts' +import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts' + +import { + TEQO_ENV_DATABASE_BY_ENV, + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, + writeRepoFile, +} from './lib/cli.mjs' +import { createNodeFaceEngine } from './lib/faceEngine.mjs' +import { + FACE_ENROLL_DEFAULT_OUT_DIR, + faceEnrollReportStamp, + formatFaceEnrollReport, + parseFaceEnrollCliArgs, +} from './lib/faceEnrollPlan.mjs' + +loadCliEnv() + +const die = dieWithLabel('faces:enroll') + +const WRITE_CONFIRM_FLAG = 'FACE_ENROLL_CONFIRM' + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +const HELP = ` +Uso: pnpm faces:enroll --label "" --selfie [opções] + +Inscreve uma pessoa no índice da busca por selfie (/fotos/encontre). A selfie é +lida do disco e processada localmente (mesmos modelos do navegador): exatamente +1 rosto é exigido. O vetor fica no servidor; a imagem nunca é guardada. + +Modos: + (sem flag) plano/dry-run — detecta o rosto e resolve o consentimento; sem escrita + --apply grava a pessoa no índice (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) + +Opções: + --label nome interno da pessoa (nunca aparece no site) + --selfie foto com um único rosto + --subject re-enrollment: substitui o vetor e limpa os vínculos derivados + --out diretório dos recibos (default ${FACE_ENROLL_DEFAULT_OUT_DIR}) + --help esta ajuda + +Ambiente: + DATABASE_URL alvo (obrigatório em todos os modos) + +--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} +casando o banco exato. O consentimento de adesão (chave ${FACE_INDEX_CONSENT_KEY}) precisa +existir no admin; sem ele o fluxo falha fechado. +` + +const writeReport = async (options, report, suffix = '') => { + const stamp = faceEnrollReportStamp(report.runAt) + const relativePath = join(options.out, 'reports', `face-enroll-${stamp}${suffix}.json`) + const body = JSON.stringify(report, null, 2) + await writeRepoFile({ + label: 'faces:enroll', + root: process.cwd(), + relativePath, + body, + }) + return join(process.cwd(), relativePath) +} + +async function main() { + const options = parseFaceEnrollCliArgs(process.argv.slice(2)) + if (options.help) { + console.log(HELP) + process.exit(0) + } + + if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') + + const mode = options.apply ? 'apply' : 'plan' + const runAt = new Date().toISOString() + const startedAt = Date.now() + console.log(`[faces:enroll] alvo: ${databaseTarget()} | modo: ${mode}`) + + if (mode === 'apply') { + assertWriteConfirm({ + label: 'faces:enroll', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm faces:enroll --label "" --selfie --apply', + }) + assertEnvironmentDatabaseTarget() + } + + try { + // Readability probe only: the bytes are decoded by `prepareFaceImage` + // below; this gives the operator a friendly refusal before the engine. + await readFile(options.selfie) + } catch (error) { + die(`não foi possível ler a selfie ${options.selfie}: ${error?.message ?? error}`) + } + + const engine = await createNodeFaceEngine(process.cwd()).catch((error) => { + die(error.message) + }) + + const { prepareFaceImage } = + await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts') + let descriptors + try { + descriptors = await engine.detectPixels(await prepareFaceImage(options.selfie)) + } catch (error) { + die(`engine falhou ao processar a selfie: ${error?.message ?? error}`) + } + + const baseReport = { + runAt, + mode, + target: databaseTarget(), + model: FACE_SEARCH_MODEL, + label: options.label.trim(), + subjectId: options.subject, + subjectAction: null, + faceCount: descriptors.length, + allowed: descriptors.length === 1, + reason: + descriptors.length === 1 + ? null + : descriptors.length === 0 + ? 'nenhum rosto detectado — use uma selfie nítida, de frente e bem iluminada' + : 'mais de um rosto na selfie — use uma foto com apenas uma pessoa', + consentKey: FACE_INDEX_CONSENT_KEY, + consentHash: null, + durationMs: 0, + } + + if (!baseReport.allowed) { + const report = { ...baseReport, durationMs: Date.now() - startedAt } + const reportPath = await writeReport(options, report) + for (const line of formatFaceEnrollReport(report)) console.log(line) + console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`) + die('enrollment recusado — detalhe no recibo acima.') + } + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts') + const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY) + if (!consent) { + const report = { ...baseReport, durationMs: Date.now() - startedAt } + await writeReport(options, report) + for (const line of formatFaceEnrollReport(report)) console.log(line) + die( + `consentimento de adesão não configurado (chave ${FACE_INDEX_CONSENT_KEY}) — o fluxo falha fechado.`, + ) + } + + const consentHash = consent.contentHash + if (mode === 'plan') { + const report = { + ...baseReport, + consentHash, + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatFaceEnrollReport(report)) console.log(line) + console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`) + process.exit(0) + } + + const { enrollFaceSubject } = + await import('../src/utilities/faceSubjects/faceSubjectEnrollment.ts') + const enrolled = await enrollFaceSubject({ + payload, + label: baseReport.label, + descriptor: descriptors[0], + consent: { id: consent.id, contentHash: consentHash }, + subjectId: options.subject, + }) + + const report = { + ...baseReport, + subjectId: enrolled.id, + subjectAction: enrolled.created ? 'created' : 'updated', + consentHash, + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatFaceEnrollReport(report)) console.log(line) + console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`) + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/scripts/index-archive-faces.mjs b/scripts/index-archive-faces.mjs new file mode 100644 index 00000000..afc606c6 --- /dev/null +++ b/scripts/index-archive-faces.mjs @@ -0,0 +1,266 @@ +/** + * C234 — 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. + * + * Modes: + * pnpm faces:index plan/dry-run (default) — the queue; no engine, no write + * pnpm faces:index --apply processes the queue; requires + * 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) + * + * Options: --limit (canary), --refresh (ignores the marker), --out + * (receipts, default `data/face`), --help. + * + * Runbook: on the workstation, against the stack env — see + * docs/ops/teqo-1313-deploy.md. + */ +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, + mirroredMediaRequired, + writeRepoFile, +} from './lib/cli.mjs' +import { createNodeFaceEngine } from './lib/faceEngine.mjs' +import { + FACE_INDEX_DEFAULT_OUT_DIR, + faceIndexReportStamp, + formatFaceIndexInventory, + formatFaceIndexReport, + parseFaceIndexCliArgs, + summarizeFaceIndexInventory, + summarizeFaceIndexResults, +} from './lib/faceIndexPlan.mjs' + +loadCliEnv() + +const die = dieWithLabel('faces:index') + +const WRITE_CONFIRM_FLAG = 'FACE_INDEX_CONFIRM' + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +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. + +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) + +Opções: + --limit --apply: processa no máximo n fotos (canário) + --refresh ignora o marcador de revisão e reprocessa tudo + --out diretório dos recibos (default ${FACE_INDEX_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, TEQO_ENV=${envLines} +casando o banco exato e as quatro envs S3_* quando a mídia for remota. O plano e o +--verify são read-only. +` + +const writeReport = async (options, report, suffix = '') => { + const stamp = faceIndexReportStamp(report.runAt) + const relativePath = join(options.out, 'reports', `face-index-${stamp}${suffix}.json`) + const body = JSON.stringify(report, null, 2) + await writeRepoFile({ + label: 'faces:index', + root: process.cwd(), + relativePath, + body, + }) + return join(process.cwd(), relativePath) +} + +async function main() { + const options = parseFaceIndexCliArgs(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' : options.verify ? 'verify' : 'plan' + const runAt = new Date().toISOString() + console.log(`[faces:index] alvo: ${databaseTarget()} | modo: ${mode}`) + + if (mode === 'apply') { + assertWriteConfirm({ + label: 'faces:index', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm faces:index --apply', + }) + assertEnvironmentDatabaseTarget() + const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts') + const storage = resolveS3StorageEnv(process.env) + if (mirroredMediaRequired({ s3Enabled: storage.enabled })) { + die( + 'escrita em produção/remoto exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o acervo não está acessível no alvo.', + ) + } + } + + 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') + + const payload = await getPayload({ config }) + const startedAt = Date.now() + + if (mode === 'verify') { + const [queue, subjects, consent] = await Promise.all([ + listArchivePhotoFaceIndexQueue({ payload }), + listFaceSubjects(payload), + getConsentByKey(payload, FACE_INDEX_CONSENT_KEY), + ]) + const inventory = summarizeFaceIndexInventory({ + queue, + subjects, + currentConsentHash: consent?.contentHash ?? null, + }) + const report = { + runAt, + mode, + target: databaseTarget(), + model: FACE_SEARCH_MODEL, + ...inventory, + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report, '-verify') + 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`) + } + if (problems.length > 0) die(problems.join('; ')) + process.exit(0) + } + + const queue = await listArchivePhotoFaceIndexQueue({ + payload, + refresh: options.refresh, + limit: mode === 'plan' ? undefined : (options.limit ?? undefined), + }) + + if (mode === 'plan') { + const report = { + runAt, + mode, + target: databaseTarget(), + model: FACE_SEARCH_MODEL, + queue: { + 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: [] }, + preview: queue.items.slice(0, options.limit ?? 20).map((item) => ({ photoId: item.id })), + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatFaceIndexReport(report)) console.log(line) + console.log(`\n[faces:index] recibo JSON: ${reportPath}`) + 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).', + ) + } + + const engine = await createNodeFaceEngine(process.cwd()).catch((error) => { + die(error.message) + }) + + const results = [] + for (const item of queue.items) { + const result = await indexArchivePhotoFaces({ + payload, + item, + indexKey: queue.indexKey, + analyze: engine.detectPixels, + }) + results.push({ photoId: item.id, ...result }) + const detail = + result.status === 'failed' + ? ` [${result.stage}]: ${result.error}` + : result.matchedSubjects.length > 0 + ? ` → ${result.matchedSubjects.length} vínculo(s)` + : '' + console.log(`[faces:index] ${result.status}${detail} foto ${item.id}`) + } + + const summary = summarizeFaceIndexResults(results) + const report = { + runAt, + mode, + target: databaseTarget(), + model: FACE_SEARCH_MODEL, + queue: { + indexKey: queue.indexKey, + totalApproved: queue.totalApproved, + stale: queue.stale, + eligibleSubjects: queue.eligibleSubjects, + items: queue.items.length, + }, + summary, + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatFaceIndexReport(report)) console.log(line) + console.log(`\n[faces:index] recibo JSON: ${reportPath}`) + + if (summary.indexed === 0 && results.length > 0) { + die('nenhuma foto foi processada com sucesso — falhas listadas no recibo.') + } + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index cf6d360e..477e9d39 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -55,6 +55,10 @@ export const E2E_CURATED_SPECS = [ // media route) is a new contract and the migration makes every PR of this // delivery high-risk (curated only). 'frontendFotos', + // C234 — deliberate: the selfie search page/endpoint (Consent fail-closed, + // only consented subjects, only approved photos) is a new contract and the + // migration makes every PR of this delivery high-risk (curated only). + 'frontendFotosSelfie', ] /** @@ -221,7 +225,29 @@ export const E2E_AFFECTED_MANIFEST = [ // The canonical names of the "pessoa pública" facet. 'src/lib/publicFigureCatalog.ts', ], - specs: ['frontendFotos'], + 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). + prefixes: [ + 'src/app/(frontend)/api/fotos', + 'src/lib/faceSearch.ts', + 'src/utilities/faceSubjects', + 'src/collections/FaceSubject.ts', + 'src/collections/Consent.ts', + 'src/utilities/campaignConsent.ts', + 'src/lib/campaignConsentKeys.ts', + ], + specs: ['frontendFotosSelfie'], + }, + { + // C234 — the bounded streaming body reader extracted from the beacon: both + // anonymous JSON routes consume it, so a diff there wakes both specs. + prefixes: ['src/utilities/boundedRequestBody.ts'], + specs: ['frontendConteudos', 'frontendFotosSelfie'], }, { // S21 — the campaign footer owns the conditional "Jingles" discovery link @@ -457,7 +483,13 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/app/(campaign)/campanha/actions/speech.ts', 'src/app/(campaign)/campanha/actions/reels.ts', ], - specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut', 'campaignReel'], + specs: [ + 'frontend', + 'campaignNewsletter', + 'campaignSpeechCut', + 'campaignReel', + 'frontendFotosSelfie', + ], }, { // Web Push client — the opt-in toast mounts on the campaign shell, so a @@ -549,7 +581,7 @@ export const E2E_AFFECTED_MANIFEST = [ // The role predicates drive the vertical gate and the assistant surfaces. 'src/lib/campaignRoles', ], - specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel'], + specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel', 'frontendFotosSelfie'], }, { // C167 — the unlisted public page of a cut: 200 with the stored file and diff --git a/scripts/lib/faceEngine.mjs b/scripts/lib/faceEngine.mjs new file mode 100644 index 00000000..68924300 --- /dev/null +++ b/scripts/lib/faceEngine.mjs @@ -0,0 +1,84 @@ +/** + * C234 — the Node face engine of the maintenance CLIs: the SAME face-api nets + * the visitor's browser loads (`tinyFaceDetector` + `faceLandmark68Net` + + * `faceRecognitionNet`), with the same options, so a descriptor computed by the + * enrollment/index CLI is comparable with the one computed on the device. The + * wasm backend is the package's own Node pairing (`face-api.node-wasm.js` + + * `@tensorflow/tfjs-backend-wasm`) — no native `tfjs-node` build, no headless + * browser, no network. + * + * The engine only turns prepared pixels into descriptors; the caller owns the + * image preparation (`prepareFaceImage`) and the distance math + * (`src/lib/faceSearch.ts`). It is a manual smoke dependency, never in the + * test suite (the suites inject a stub analyzer). + */ +import { createRequire } from 'node:module' +import path from 'node:path' + +const require = createRequire(import.meta.url) + +/** + * Loads the nets once per process. Throws a named error when the wasm assets or + * the models are missing — a broken install must fail before any DB write. + * + * @param {string} repoRoot + */ +export const createNodeFaceEngine = async (repoRoot) => { + const { FACE_DETECTOR_INPUT_SIZE, FACE_DETECTOR_SCORE_THRESHOLD } = + await import('../../src/lib/faceSearch.ts') + + let faceapi + try { + // The tfjs runtime is required explicitly first: it is the exact instance + // the node-wasm build and the wasm backend register against (same module + // cache), and it keeps the dependency honest for the static checks. + require('@tensorflow/tfjs') + faceapi = require('@vladmandic/face-api/dist/face-api.node-wasm.js') + const { setWasmPaths } = require('@tensorflow/tfjs-backend-wasm') + setWasmPaths(path.join(repoRoot, 'node_modules/@tensorflow/tfjs-backend-wasm/dist/')) + } catch (error) { + throw new Error( + `engine indisponível (instale @vladmandic/face-api e @tensorflow/tfjs-backend-wasm): ${ + error instanceof Error ? error.message : error + }`, + ) + } + + const tf = faceapi.tf + await tf.setBackend('wasm') + await tf.ready() + + const modelDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model') + await faceapi.nets.tinyFaceDetector.loadFromDisk(modelDir) + await faceapi.nets.faceLandmark68Net.loadFromDisk(modelDir) + await faceapi.nets.faceRecognitionNet.loadFromDisk(modelDir) + + const options = new faceapi.TinyFaceDetectorOptions({ + inputSize: FACE_DETECTOR_INPUT_SIZE, + scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD, + }) + + return { + /** + * Descriptors of every detected face, in detection order. + * @param {{ pixels: Uint8Array, width: number, height: number }} image + * @returns {Promise} + */ + detectPixels: async ({ pixels, width, height }) => { + const tensor = tf.tensor3d( + new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength), + [height, width, 3], + 'int32', + ) + try { + const results = await faceapi + .detectAllFaces(tensor, options) + .withFaceLandmarks() + .withFaceDescriptors() + return results.map((result) => Array.from(result.descriptor)) + } finally { + tensor.dispose() + } + }, + } +} diff --git a/scripts/lib/faceEnrollPlan.mjs b/scripts/lib/faceEnrollPlan.mjs new file mode 100644 index 00000000..8866fbdd --- /dev/null +++ b/scripts/lib/faceEnrollPlan.mjs @@ -0,0 +1,91 @@ +/** + * C234 — pure planning/reporting of `pnpm faces:enroll`: the argv parser and + * the human lines + JSON receipt of the enrollment. No I/O of its own — the + * engine, the Consent resolution and the Payload write arrive as parameters, + * so the unit spec drives everything with fakes. + */ + +const USAGE = + 'uso: `pnpm faces:enroll --label "" --selfie [--subject ] [--apply]`' + +export const FACE_ENROLL_DEFAULT_OUT_DIR = 'data/face' + +export const faceEnrollReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-') + +/** + * `pnpm faces:enroll` argv parser — pure, so the unit spec pins it (the CLI + * owns the exit and the help text). The default mode is the plan/dry-run; + * `--label` and `--selfie` are required in both modes so the plan shows the + * real operation, not an abstract one. + * + * @param {string[]} [argv] + * @returns {{ apply: boolean, label: string | null, selfie: string | null, subject: number | null, out: string, help: boolean }} + */ +export const parseFaceEnrollCliArgs = (argv = process.argv.slice(2)) => { + const options = { + apply: false, + label: null, + selfie: null, + subject: null, + out: FACE_ENROLL_DEFAULT_OUT_DIR, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = () => { + const next = argv[++index] + if (next === undefined || next.startsWith('--')) { + throw new Error(`faltou valor para ${arg} — ${USAGE}.`) + } + return next + } + + if (arg === '--help' || arg === '-h') options.help = true + else if (arg === '--apply') options.apply = true + else if (arg === '--label') options.label = value() + else if (arg === '--selfie') options.selfie = value() + else if (arg === '--subject') options.subject = Number(value()) + else if (arg === '--out') options.out = value() + else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`) + } + + if (options.help) return options + if (!options.label?.trim()) throw new Error(`--label é obrigatório — ${USAGE}.`) + if (!options.selfie?.trim()) throw new Error(`--selfie é obrigatório — ${USAGE}.`) + if (options.subject !== null && (!Number.isSafeInteger(options.subject) || options.subject < 1)) { + throw new Error('--subject deve ser um id >= 1.') + } + if (options.out.split(/[\\/]/).includes('..')) { + throw new Error('--out não pode escapar do diretório do repo (sem "..").') + } + return options +} + +/** + * The receipt + human lines of one enrollment: what was detected (face count — + * never the descriptor), the subject row and the consent snapshot. A refusal + * (0 or >1 faces) is a report with `allowed: false` and its reason. + * + * @param {{ runAt: string, mode: string, target: string, model: string, label: string, subjectId: number | null, subjectAction: 'created' | 'updated' | null, faceCount: number, allowed: boolean, reason?: string | null, consentKey: string, consentHash: string | null, durationMs: number }} report + */ +export const formatFaceEnrollReport = (report) => { + const lines = [ + `[faces:enroll] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`, + `[faces:enroll] selfie: ${report.faceCount} rosto(s) detectado(s)`, + ] + if (!report.allowed) { + lines.push(`[faces:enroll] RECUSADO: ${report.reason ?? 'selfie fora do contrato'}`) + return lines + } + if (report.subjectAction === null) { + lines.push(`[faces:enroll] plano: ${report.label} (nada gravado; confirme com --apply)`) + return lines + } + lines.push( + `[faces:enroll] ${report.subjectAction === 'created' ? 'criada' : 'atualizada'}: #${report.subjectId} (${report.label})`, + `[faces:enroll] consentimento: ${report.consentKey} · hash ${report.consentHash ?? '(ausente)'}`, + `[faces:enroll] próximo passo: pnpm faces:index --apply (reprocessa as fotos aprovadas)`, + ) + return lines +} diff --git a/scripts/lib/faceIndexPlan.mjs b/scripts/lib/faceIndexPlan.mjs new file mode 100644 index 00000000..24961d36 --- /dev/null +++ b/scripts/lib/faceIndexPlan.mjs @@ -0,0 +1,170 @@ +/** + * C234 — 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 + * parameters, so the unit spec drives everything with fakes. + */ + +const USAGE = 'uso: `pnpm faces:index [--apply|--verify] [--limit ] [--refresh] [--out ]`' + +export const FACE_INDEX_DEFAULT_OUT_DIR = 'data/face' + +export const faceIndexReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-') + +/** + * `pnpm faces:index` 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. + * + * @param {string[]} [argv] + * @returns {{ apply: boolean, verify: boolean, limit: number | null, refresh: boolean, out: string, help: boolean }} + */ +export const parseFaceIndexCliArgs = (argv = process.argv.slice(2)) => { + const options = { + apply: false, + verify: false, + limit: null, + refresh: false, + out: FACE_INDEX_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 === '--verify') options.verify = true + else if (arg === '--refresh') options.refresh = 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.apply && options.verify) { + throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`) + } + 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 +} + +/** + * Aggregates one apply run: photos indexed (and how many subjects each linked), + * photos refused by the engine/IO with stage+reason. + * + * @param {Array<{ photoId: number, status: string, matchedSubjects?: 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 + + 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) + } else if (result.status === 'failed') { + failed += 1 + failures.push({ + photoId: result.photoId, + stage: result.stage ?? null, + error: result.error ?? null, + }) + } + } + + return { + indexed, + failed, + linkedPhotos, + linksBySubject: [...linkedBySubject.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([subjectId, photos]) => ({ subjectId, photos })), + 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. + * + * @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 + */ +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, + } +} + +/** One line per counted state of the inventory (pt-BR, operator-facing). */ +export const formatFaceIndexInventory = (inventory, model) => [ + `[faces:index] modelo: ${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)`, + ), +] + +/** 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})`, + ] + + if (report.mode !== 'plan') { + lines.push( + `[faces:index] processadas: ${report.summary.indexed} | vinculadas a alguém: ${report.summary.linkedPhotos} | 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 ?? []) { + lines.push(`[faces:index] ✗ foto ${failure.photoId} [${failure.stage}]: ${failure.error}`) + } + + return lines +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 64c599f9..925a308f 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -76,6 +76,8 @@ 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/faceIndexPlan.mjs', 'scripts/lib/flickrApi.mjs', 'scripts/lib/flickrPlan.mjs', 'scripts/lib/github-api.mjs', diff --git a/src/app/(frontend)/api/content-events/route.ts b/src/app/(frontend)/api/content-events/route.ts index 8b5efe6c..1bd9d952 100644 --- a/src/app/(frontend)/api/content-events/route.ts +++ b/src/app/(frontend)/api/content-events/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { getCardModel } from '@/lib/cardModels' import { contentEventRequestSchema } from '@/lib/schemas/contentEvent' import { getStateDeputyCard } from '@/lib/stateDeputyCatalog' +import { readBoundedRequestBody } from '@/utilities/boundedRequestBody' import { checkContentEventRateLimit, contentEventClientKey, @@ -37,43 +38,10 @@ const MAX_BODY_BYTES = 4 * 1024 const silentResponse = (status: number): NextResponse => new NextResponse(null, { status, headers: NO_STORE }) -/** - * Reads the body with a hard byte ceiling, streaming and aborting as soon as - * the ceiling is crossed — a chunked body without `content-length` cannot make - * the route buffer unbounded input. `null` means "refused". - */ -const readBoundedBody = async (request: Request): Promise => { - const body = request.body - if (!body) return '' - - const reader = body.getReader() - const decoder = new TextDecoder() - let text = '' - let bytes = 0 - - try { - for (;;) { - const { done, value } = await reader.read() - if (done) break - - bytes += value.byteLength - if (bytes > MAX_BODY_BYTES) { - await reader.cancel().catch(() => undefined) - return null - } - text += decoder.decode(value, { stream: true }) - } - return text + decoder.decode() - } catch { - await reader.cancel().catch(() => undefined) - return null - } -} - export const POST = async (request: Request): Promise => { if (!isSameOriginRequest(request)) return silentResponse(403) - const rawBody = await readBoundedBody(request) + const rawBody = await readBoundedRequestBody(request, MAX_BODY_BYTES) if (rawBody === null) return silentResponse(400) let body: unknown diff --git a/src/app/(frontend)/api/fotos/selfie/route.ts b/src/app/(frontend)/api/fotos/selfie/route.ts new file mode 100644 index 00000000..c24be0bb --- /dev/null +++ b/src/app/(frontend)/api/fotos/selfie/route.ts @@ -0,0 +1,130 @@ +import config from '@payload-config' +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 { faceSearchRequestSchema } from '@/lib/schemas/faceSearch' +import { getApprovedArchivePhotoItems } from '@/utilities/archivePhotos/archivePhotoReads' +import { readBoundedRequestBody } from '@/utilities/boundedRequestBody' +import { getConsentByKey } from '@/utilities/campaignConsent' +import { + checkContentEventRateLimit, + contentEventClientKey, +} from '@/utilities/content/contentEventRateLimit' +import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment' +import { + getFaceSubjectMatchedPhotoIds, + listSearchableFaceSubjects, +} from '@/utilities/faceSubjects/faceSubjectReads' +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. + * + * 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. + */ + +const NO_STORE = { 'Cache-Control': 'no-store' } as const + +/** The descriptor JSON is ~2 KB; anything past 4 KB is not ours. */ +const MAX_BODY_BYTES = 4 * 1024 + +/** Requests per 10-minute window per hashed IP — strict on purpose. */ +const FACE_SEARCH_REQUESTS_PER_WINDOW = 20 + +const jsonResponse = (body: unknown, status = 200): NextResponse => + NextResponse.json(body, { status, headers: NO_STORE }) + +export const POST = async (request: Request): Promise => { + if (!isSameOriginRequest(request)) return jsonResponse({ ok: false, error: 'origin' }, 403) + + const rawBody = await readBoundedRequestBody(request, MAX_BODY_BYTES) + if (rawBody === null) return jsonResponse({ ok: false, error: 'body' }, 400) + + let body: unknown + try { + body = JSON.parse(rawBody) + } catch { + return jsonResponse({ ok: false, error: 'body' }, 400) + } + + const parsed = faceSearchRequestSchema.safeParse(body) + if (!parsed.success) return jsonResponse({ ok: false, error: 'body' }, 400) + + // The registry/hash owner is shared with the beacon, but the budget window is + // NOT: the caller namespaces its key so beacon events and selfie searches + // never consume each other's counters. + const clientKey = contentEventClientKey(request.headers) + if ( + !checkContentEventRateLimit(clientKey && `selfie:${clientKey}`, FACE_SEARCH_REQUESTS_PER_WINDOW) + ) { + return jsonResponse({ ok: false, error: 'rate-limited' }, 429) + } + + const payload = await getPayload({ config }) + // Direct read (no cache): the kill switch and the consent 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([ + payload.findGlobal({ slug: 'photoAlbum', depth: 0 }).catch(() => null), + getConsentByKey(payload, FACE_SEARCH_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 (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 }) + } + + if (!match) return jsonResponse({ ok: true, found: false }) + + const [matchedIds, approved] = await Promise.all([ + getFaceSubjectMatchedPhotoIds(payload, match.id), + getApprovedArchivePhotoItems(), + ]) + const linked = new Set(matchedIds) + const photos = approved + .filter((item) => linked.has(item.id)) + .slice(0, FACE_SEARCH_RESULT_LIMIT) + .map(toFaceSearchPhotoView) + + if (photos.length === 0) return jsonResponse({ ok: true, found: false }) + + return jsonResponse({ ok: true, found: true, photos }) +} diff --git a/src/app/(frontend)/fotos/encontre/page.tsx b/src/app/(frontend)/fotos/encontre/page.tsx new file mode 100644 index 00000000..2fc240de --- /dev/null +++ b/src/app/(frontend)/fotos/encontre/page.tsx @@ -0,0 +1,114 @@ +import config from '@payload-config' +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { getPayload } from 'payload' + +import { ConsentText } from '@/components/campaign/shared/ConsentText' +import { ARCHIVE_PHOTO_FOCUS } from '@/components/fotos/archivePhotoClasses' +import { ArchivePhotoPageHeader } from '@/components/fotos/ArchivePhotoPageHeader' +import { SelfieSearchClosed } from '@/components/fotos/SelfieSearchClosed' +import { SelfieSearchFlow } from '@/components/fotos/SelfieSearchFlow' +import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog' +import { + ARCHIVE_PHOTO_ALBUM_ENTRY_PATH, + ARCHIVE_PHOTO_ALBUM_PATH, +} from '@/lib/archivePhotoPublicCatalog' +import { FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys' +import { getConsentByKey } from '@/utilities/campaignConsent' +import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads' +import { getCachedGlobal } from '@/utilities/globalReads' +import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' + +export const dynamic = 'force-dynamic' + +const ENTRY_TITLE = 'Encontre você nas fotos — Jorge Solla 1313' +const ENTRY_DESCRIPTION = + 'Busque por uma selfie sua as fotos públicas aprovadas em que você aparece. A imagem não sai do seu dispositivo.' + +const getCachedPhotoAlbum = getCachedGlobal('photoAlbum') + +/** + * C234 — the selfie search page (`/fotos/encontre`), a surface of the album: + * the same album kill switch (`photoAlbum.published`) and the feature flag + * (`selfieSearchEnabled`, default off) gate the route with the album's 404; the + * query Consent resolved by stable key is the legal gate — without it the page + * still exists but renders only the fail-closed state, and the API refuses + * independently. Noindex on purpose: the canonical surface is the album. + */ +export async function generateMetadata(): Promise { + const [album, globalMetadata] = await Promise.all([ + getCachedPhotoAlbum(), + getCachedGlobal('metadata')(), + ]) + if (album.published === false || album.selfieSearchEnabled !== true) notFound() + + const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) + const canonicalUrl = absoluteSitePath(siteUrl, ARCHIVE_PHOTO_ALBUM_ENTRY_PATH) + const title = `${ENTRY_TITLE} | ${siteName}` + + return { + title, + description: ENTRY_DESCRIPTION, + robots: { index: false, follow: false }, + ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), + openGraph: { + type: 'website', + locale: 'pt-BR', + ...(canonicalUrl ? { url: canonicalUrl } : {}), + siteName, + title, + description: ENTRY_DESCRIPTION, + images: [], + }, + twitter: { + card: 'summary', + title, + description: ENTRY_DESCRIPTION, + creator: twitterCreator, + images: [], + }, + } +} + +export default async function FotosEncontrePage() { + const [album, showConteudos] = await Promise.all([ + getCachedPhotoAlbum(), + hasPublishedContentPieces(), + ]) + if (album.published === false || album.selfieSearchEnabled !== true) notFound() + + const payload = await getPayload({ config }) + const consent = await getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY) + const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl) + ? album.removalChannelUrl.trim() + : null + + return ( + <> + +
+
+ +
+ {consent ? ( + } + removalChannelUrl={removalChannelUrl} + /> + ) : ( +
+ +
+ )} +
+ + ) +} diff --git a/src/app/(frontend)/fotos/page.tsx b/src/app/(frontend)/fotos/page.tsx index 10e47716..8554e1b6 100644 --- a/src/app/(frontend)/fotos/page.tsx +++ b/src/app/(frontend)/fotos/page.tsx @@ -9,6 +9,7 @@ import { ArchivePhotoEmptyState, ArchivePhotoNoResults, } from '@/components/fotos/ArchivePhotoStates' +import { SelfieSearchEntry } from '@/components/fotos/SelfieSearchEntry' import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog' import { ARCHIVE_PHOTO_ALBUM_PATH, @@ -126,7 +127,17 @@ export default async function FotosPage({ ) : ( <> - + {album.selfieSearchEnabled === true ? ( +
+ +
+ ) : null} +
diff --git a/src/collections/ArchivePhoto.ts b/src/collections/ArchivePhoto.ts index 0d10accc..10a6a465 100644 --- a/src/collections/ArchivePhoto.ts +++ b/src/collections/ArchivePhoto.ts @@ -165,6 +165,12 @@ const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({ }) => { 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) { @@ -276,6 +282,41 @@ const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({ 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: { @@ -502,6 +543,7 @@ export const ArchivePhoto: CollectionConfig = { '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, } diff --git a/src/collections/Consent.ts b/src/collections/Consent.ts index 24f44348..ceef3787 100644 --- a/src/collections/Consent.ts +++ b/src/collections/Consent.ts @@ -22,6 +22,7 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ leaderships, supporters, voteSupporters, + faceSubjects, ] = await Promise.all([ req.payload.count({ collection: 'subscription', @@ -65,6 +66,14 @@ 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]> = [ @@ -75,6 +84,7 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ [leaderships.totalDocs, 'lideranças'], [supporters.totalDocs, 'apoiadores'], [voteSupporters.totalDocs, 'apoiadores (intenção de voto)'], + [faceSubjects.totalDocs, 'pessoas no índice de busca por selfie'], ] const used = references.filter(([count]) => count > 0) if (used.length === 0) return diff --git a/src/collections/FaceSubject.ts b/src/collections/FaceSubject.ts new file mode 100644 index 00000000..802d600a --- /dev/null +++ b/src/collections/FaceSubject.ts @@ -0,0 +1,135 @@ +import type { CollectionConfig } from 'payload' + +import { payloadAdminOnly } from '@/utilities/campaignAccess' + +/** + * C234 — one person who ADHERED to the selfie search index (A/C scope of the + * gate, PR #1370): the enrollment is an ops act of the assessoria with the + * person (signed consent, `pnpm faces:enroll`), never self-service and never + * inferred from the archive. + * + * This is not a people registry: it has no link to `Contact`/leadership, no + * public read and no surface of its own — it exists to (a) hold the consented + * descriptor that lets the person find themselves and (b) carry the audit + * trail of the adherence (`consent` + `consentHash` + `model` + status). The + * descriptor is written only by the CLI and never leaves the server; the + * matched photos are a derived link the batch replaces on every run. A + * `removed` subject keeps its row (the attendance record) but not the vector. + */ +export const FaceSubject: CollectionConfig = { + slug: 'faceSubject', + labels: { + singular: 'Pessoa no índice facial', + plural: 'Pessoas no índice facial', + }, + admin: { + group: 'Comunicação', + description: + 'Pessoas que consentiram entrar no índice da busca por selfie (/fotos/encontre). Operado pela CLI faces:enroll/faces:index; a remoção é registrada no status.', + useAsTitle: 'label', + defaultColumns: ['label', 'status', 'model', 'removedAt'], + }, + access: { + create: payloadAdminOnly, + read: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, + }, + fields: [ + { + name: 'label', + type: 'text', + label: 'Nome (interno)', + required: true, + admin: { + description: 'Como a assessoria identifica a pessoa; nunca aparece no site.', + }, + }, + { + name: 'consent', + type: 'relationship', + relationTo: 'consent', + label: 'Consentimento (adesão)', + required: true, + index: true, + admin: { + description: 'O Consent da adesão ao índice (chave estável busca-selfie-indice).', + }, + }, + { + name: 'consentHash', + type: 'text', + label: 'Hash do consentimento', + admin: { + readOnly: true, + description: + 'Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.', + }, + }, + { + name: 'model', + type: 'text', + label: 'Modelo', + admin: { + readOnly: true, + description: 'Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).', + }, + }, + { + name: 'enrolledAt', + type: 'date', + label: 'Inscrita em', + admin: { + readOnly: true, + description: + 'Muda a cada enrollment/re-enrollment; é o que marca o lote como stale (o updated_at muda também quando o lote escreve os vínculos).', + }, + }, + { + name: 'vector', + type: 'json', + label: 'Descriptor', + admin: { + hidden: true, + readOnly: true, + }, + }, + { + name: 'status', + type: 'select', + label: 'Status', + required: true, + index: true, + defaultValue: 'active', + options: [ + { label: 'Ativa', value: 'active' }, + { label: 'Removida do índice', value: 'removed' }, + ], + admin: { + description: + 'Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.', + }, + }, + { + name: 'removedAt', + type: 'date', + label: 'Removida em', + admin: { + readOnly: true, + description: 'Data do atendimento da saída do índice.', + }, + }, + { + name: 'matchedPhotos', + type: 'relationship', + relationTo: 'archivePhoto', + hasMany: true, + label: 'Fotos vinculadas', + admin: { + readOnly: true, + description: + 'Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.', + }, + }, + ], +} diff --git a/src/components/fotos/ArchivePhotoCard.tsx b/src/components/fotos/ArchivePhotoCard.tsx index 31837344..0ce96b63 100644 --- a/src/components/fotos/ArchivePhotoCard.tsx +++ b/src/components/fotos/ArchivePhotoCard.tsx @@ -9,6 +9,49 @@ import { import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses' +/** + * C233 — the card body every photo grid shares (album and, without the + * third-party line, the C234 selfie-search result): image through the + * same-origin private proxy, curated title, meta line and the optional + * "Quem aparece". The caller owns the link/href contract. + */ +export const ArchivePhotoCardBody = ({ + item, +}: { + item: Pick< + ArchivePhotoPublicItem, + 'alt' | 'title' | 'metaLabel' | 'peopleLabel' | 'thumbnailPath' + > +}) => ( + <> + {/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */} + {item.alt} +
+

+ {item.title} +

+ {item.metaLabel ? ( +

+ {item.metaLabel} +

+ ) : null} + {item.peopleLabel ? ( +

{item.peopleLabel}

+ ) : null} +
+ +) + /** * C233 — one photo of the grid (artefato: cenas 01/02/07): the whole card is a * single link to the same filtered URL plus `?foto=`, one tab stop, yellow @@ -29,31 +72,7 @@ export const ArchivePhotoCard = ({ className={`${ARCHIVE_PHOTO_CARD} group`} data-photo-id={item.id} > - {/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */} - {item.alt} -
-

- {item.title} -

- {item.metaLabel ? ( -

- {item.metaLabel} -

- ) : null} - {item.peopleLabel ? ( -

{item.peopleLabel}

- ) : null} -
+ ) diff --git a/src/components/fotos/ArchivePhotoFilters.tsx b/src/components/fotos/ArchivePhotoFilters.tsx index ceb71c6e..eb027044 100644 --- a/src/components/fotos/ArchivePhotoFilters.tsx +++ b/src/components/fotos/ArchivePhotoFilters.tsx @@ -18,6 +18,7 @@ import { ARCHIVE_PHOTO_CHIP, ARCHIVE_PHOTO_FOCUS, ARCHIVE_PHOTO_PRIMARY_BUTTON, + ARCHIVE_PHOTO_SECONDARY_BUTTON, } from './archivePhotoClasses' const FACET_MENU_HEADINGS: Record = { @@ -75,10 +76,13 @@ export const ArchivePhotoFilters = ({ params, facets, activeFilters, + searchSecondary = false, }: { params: ArchivePhotoAlbumParams facets: ArchivePhotoAlbumFacets activeFilters: readonly ArchivePhotoAlbumActiveFilter[] + /** C234 — with the selfie entry band above, the album search is the secondary action. */ + searchSecondary?: boolean }) => { const facetFilters = activeFilters.filter((filter): filter is FacetFilter => filter.facet !== 'q') const termFilter = activeFilters.find((filter) => filter.facet === 'q') @@ -123,7 +127,10 @@ export const ArchivePhotoFilters = ({ ) : null}
-
diff --git a/src/components/fotos/SelfieSearchClosed.tsx b/src/components/fotos/SelfieSearchClosed.tsx new file mode 100644 index 00000000..90853cf4 --- /dev/null +++ b/src/components/fotos/SelfieSearchClosed.tsx @@ -0,0 +1,36 @@ +import { TriangleAlert } from 'lucide-react' +import Link from 'next/link' + +import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog' + +import { ARCHIVE_PHOTO_SECONDARY_BUTTON } from './archivePhotoClasses' + +/** + * C234 — the fail-closed state of the selfie search (artefato: cena 07): the + * query Consent is not configured, so the flow never opens and no selfie can be + * captured or processed. Server-rendered on `/fotos/encontre` and reused by the + * client flow when the flag/consent flips mid-session. + */ +export const SelfieSearchClosed = () => ( +
+
+
+

+ Proteção de dados +

+

+ A busca por selfie está indisponível +

+

+ O texto de consentimento necessário ainda não está configurado. Por segurança, não podemos + receber nem processar sua selfie agora. +

+

+ Nenhuma imagem foi capturada ou enviada. +

+ + Voltar ao álbum de fotos + +
+) diff --git a/src/components/fotos/SelfieSearchEntry.tsx b/src/components/fotos/SelfieSearchEntry.tsx new file mode 100644 index 00000000..845877e8 --- /dev/null +++ b/src/components/fotos/SelfieSearchEntry.tsx @@ -0,0 +1,44 @@ +import { ChevronRight, Sparkles } from 'lucide-react' +import Link from 'next/link' + +import { ARCHIVE_PHOTO_ALBUM_ENTRY_PATH } from '@/lib/archivePhotoPublicCatalog' + +import { ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses' + +/** + * C234 — the entry band of the selfie search on `/fotos` (artefato: cena 01, + * revisada): a full-width band between the hero and the facets, never a second + * column in the hero. Rendered only while the feature flag is on and the album + * has approved photos to find; the visual contract is the artifact's + * `rounded-2xl border border-[#184e92]/15 bg-[#f9faff]` band. + */ +export const SelfieSearchEntry = () => ( + +) diff --git a/src/components/fotos/SelfieSearchFlow.tsx b/src/components/fotos/SelfieSearchFlow.tsx new file mode 100644 index 00000000..127e6b70 --- /dev/null +++ b/src/components/fotos/SelfieSearchFlow.tsx @@ -0,0 +1,886 @@ +'use client' + +import { + Camera, + ChevronLeft, + Clock, + ImageIcon, + RefreshCw, + ShieldCheck, + Smartphone, + Sparkles, + Trash2, + TriangleAlert, + Upload, + WifiOff, +} from 'lucide-react' +import Link from 'next/link' +import { useEffect, useRef, useState, type ReactNode } from 'react' + +import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog' +import type { FaceSearchPhotoView } from '@/lib/faceSearch' + +import { + ARCHIVE_PHOTO_FOCUS, + ARCHIVE_PHOTO_PRIMARY_BUTTON, + ARCHIVE_PHOTO_SECONDARY_BUTTON, +} from './archivePhotoClasses' +import { computeSelfieDescriptor, supportsFaceSearchEngine } from './faceSearchEngine' +import { SelfieSearchClosed } from './SelfieSearchClosed' +import { SelfieSearchResultCard } from './SelfieSearchResultCard' + +type SelfieSearchIntent = 'search' | 'leave-index' + +type FlowStep = + | { kind: 'consent' } + | { kind: 'pick'; intent: SelfieSearchIntent } + | { kind: 'confirm-leave' } + | { kind: 'processing'; intent: SelfieSearchIntent } + | { kind: 'results'; photos: FaceSearchPhotoView[] } + | { kind: 'empty' } + | { kind: 'no-face' } + | { kind: 'presence' } + | { kind: 'removed' } + | { kind: 'presence-missing' } + | { kind: 'unsupported' } + | { kind: 'engine-error' } + | { kind: 'rate-limited' } + | { kind: 'network' } + | { kind: 'closed' } + +type SelfieSearchResponse = + | { ok: true; found: true; photos: FaceSearchPhotoView[] } + | { ok: true; found: false } + | { ok: true; removed: boolean } + | { ok: false; error: string } + +const ACCEPTED_IMAGE = 'image/*' +const FOCUSABLE = 'a[href]:not([tabindex="-1"]), button:not([disabled]), input:not([disabled])' + +/** The wizard steps center on a readable column; the result breaks out to the page grid. */ +const STEP_SHELL = 'mx-auto w-full max-w-[650px]' + +/** + * C234 — the selfie search flow (artefato: cenas 02–13). The browser computes + * the descriptor on device and posts only the 128-float vector; the component + * never puts the image in a request. Every terminal state of the artifact has + * an exact counterpart here (empty, no-face, engine/unsupported, rate limit, + * network, closed) and none of them shows a score, a percentage or a + * third-party name. The consent text arrives rendered from the server (the + * approved Consent reachText); the opt-out is authorized by the same face + * match, and its no-match case points to the album's configured channel. + */ +export const SelfieSearchFlow = ({ + consentText, + removalChannelUrl, +}: { + consentText: ReactNode + removalChannelUrl: string | null +}) => { + const [step, setStep] = useState({ kind: 'consent' }) + const [consented, setConsented] = useState(false) + const [activeIntent, setActiveIntent] = useState('search') + const [file, setFile] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const previewRef = useRef(null) + const inputRef = useRef(null) + const resultsRef = useRef(null) + const presenceRef = useRef(null) + const pickRef = useRef(null) + const processingRef = useRef(null) + + useEffect( + () => () => { + if (previewRef.current) URL.revokeObjectURL(previewRef.current) + }, + [], + ) + + // The async transitions unmount the control that had focus; every new step + // starts at its own top (the album scrolls inside its segment container, so + // `scrollIntoView` is what reaches it) with focus on the heading region — + // keyboard/screen-reader users never land mid-step (the terminal cards focus + // and scroll themselves). + useEffect(() => { + const target = + step.kind === 'pick' + ? pickRef.current + : step.kind === 'processing' + ? processingRef.current + : step.kind === 'results' + ? resultsRef.current + : step.kind === 'presence' + ? presenceRef.current + : null + target?.scrollIntoView({ block: 'start' }) + target?.focus() + }, [step.kind]) + + const releasePreview = () => { + if (previewRef.current) URL.revokeObjectURL(previewRef.current) + previewRef.current = null + setPreviewUrl(null) + } + + const selectFile = (next: File | null) => { + releasePreview() + setFile(next) + if (next) { + previewRef.current = URL.createObjectURL(next) + setPreviewUrl(previewRef.current) + } + } + + const runSearch = async (intent: SelfieSearchIntent) => { + if (!file) return + + setStep({ kind: 'processing', intent }) + const computed = await computeSelfieDescriptor(file) + if (!computed.ok) { + setStep( + computed.reason === 'unsupported' + ? { kind: 'unsupported' } + : computed.reason === 'empty' + ? { kind: 'no-face' } + : { kind: 'engine-error' }, + ) + return + } + + try { + const response = await fetch('/api/fotos/selfie', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ vector: computed.descriptor, intent }), + }) + const body = (await response.json().catch(() => null)) as SelfieSearchResponse | null + + if (response.status === 429) return setStep({ kind: 'rate-limited' }) + if (response.status === 404 || response.status === 503) return setStep({ kind: 'closed' }) + if (!response.ok || body === null || !body.ok) return setStep({ kind: 'engine-error' }) + + if (intent === 'leave-index') { + if (!('removed' in body)) return setStep({ kind: 'engine-error' }) + return setStep(body.removed ? { kind: 'removed' } : { kind: 'presence-missing' }) + } + + if ('found' in body && body.found) return setStep({ kind: 'results', photos: body.photos }) + return setStep({ kind: 'empty' }) + } catch { + setStep({ kind: 'network' }) + } + } + + const startSelfieStep = (intent: SelfieSearchIntent) => { + if (!supportsFaceSearchEngine()) { + setStep({ kind: 'unsupported' }) + return + } + setActiveIntent(intent) + selectFile(null) + setStep({ kind: 'pick', intent }) + } + + const openPicker = () => inputRef.current?.click() + + const onFileChange = (event: React.ChangeEvent) => { + const next = event.target.files?.[0] ?? null + event.target.value = '' + selectFile(next) + } + + const reset = () => { + selectFile(null) + setConsented(false) + setStep({ kind: 'consent' }) + } + + return ( +
+ + + {step.kind === 'consent' ? ( +

+ Encontre você nas fotos +

+ ) : null} + + {step.kind === 'consent' ? ( +
+
+
+
+ + Passo 1 de 3 + +
+

+ Antes da sua selfie +

+ +
+ {consentText} +
+
+
+
+
+
+
+ +

+ Saiba como tratamos seus dados na{' '} + + Política de Privacidade + + . +

+ + + Agora não · voltar ao álbum + +
+ ) : null} + + {step.kind === 'pick' ? ( +
+ +

+ Passo 2 de 3 +

+

+ Escolha uma selfie nítida +

+

+ Olhe para a câmera, com o rosto bem iluminado e sem outras pessoas na imagem. +

+ {previewUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- local blob preview, never uploaded */} + Prévia da sua selfie + + Prévia no aparelho + +
+ ) : ( + + )} +
+
+ + +
+ ) : null} + + {step.kind === 'processing' ? ( +
+
+
+ {previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- local blob preview, never uploaded + + ) : null} +
+
+ +
+
+

+ {step.intent === 'leave-index' ? 'Saindo do índice' : 'Passo 3 de 3'} +

+

+ {step.intent === 'leave-index' + ? 'Confirmando sua saída do índice…' + : 'Procurando você nas fotos aprovadas…'} +

+

+ Mantenha esta tela aberta. Sua selfie continua somente neste aparelho. +

+
+
+
+ ) : null} + + {step.kind === 'results' ? ( +
+
+
+
+

+ Sua busca por selfie +

+

+ Encontramos você nestas fotos. + + Estas são as fotos em que encontramos você. + +

+

+ + Só registros públicos aprovados, sem nomes de terceiros. + + + Mostramos apenas registros públicos aprovados. Não exibimos nomes de outras + pessoas nem nota de semelhança. + +

+
+ +
+
+ {step.photos.map((photo) => ( + + ))} +
+ setStep({ kind: 'presence' })} /> +
+
+ ) : null} + + {step.kind === 'empty' ? ( +
+ ) +} + +const STATE_LINK = `inline-flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}` + +const StepDots = ({ current }: { current: 2 }) => ( + +) + +const PresenceBand = ({ onManage }: { onManage: () => void }) => ( +
+
+

Quer controlar sua presença no álbum?

+

+ Saia do índice ou peça a remoção de uma foto. +

+
+ +
+) + +/** + * The shared terminal-state card (artefato: cenas 06/09–13): the icon circle, + * the honest title/body and the single primary action. `tone` only changes the + * icon palette — hierarchy and spacing stay identical across states. + */ +const FlowStateCard = ({ + icon, + tone = 'info', + title, + body, + note, + action, + secondary, +}: { + icon: ReactNode + tone?: 'info' | 'danger' | 'warn' + title: string + body: string + note?: string + action: ReactNode + secondary?: ReactNode +}) => { + const sectionRef = useRef(null) + useEffect(() => { + sectionRef.current?.scrollIntoView({ block: 'start' }) + sectionRef.current?.focus() + }, []) + + const toneClass = + tone === 'danger' + ? 'bg-red-50 text-[#b42318]' + : tone === 'warn' + ? 'bg-amber-50 text-amber-800' + : 'bg-[#eef4fb] text-[#184e92]' + + return ( +
+
{icon}
+

+ {title} +

+

{body}

+ {note ?

{note}

: null} +
{action}
+ {secondary ?
{secondary}
: null} +
+ ) +} + +/** + * The opt-out confirmation (artefato: cena 08, segundo quadro) — a real dialog: + * the destructive action asks once, Esc and the backdrop cancel, Tab stays + * inside, and focus starts on the cancel action so an accidental Enter never + * erases the index entry. + */ +const LeaveConfirmDialog = ({ + onConfirm, + onCancel, +}: { + onConfirm: () => void + onCancel: () => void +}) => { + const cancelRef = useRef(null) + const dialogRef = useRef(null) + const onCancelRef = useRef(onCancel) + onCancelRef.current = onCancel + + useEffect(() => { + cancelRef.current?.focus() + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onCancelRef.current() + return + } + if (event.key !== 'Tab') return + + const focusables = dialogRef.current?.querySelectorAll(FOCUSABLE) + if (!focusables || focusables.length === 0) return + const first = focusables[0]! + const last = focusables[focusables.length - 1]! + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + return ( +
+ + +
+ + ) +} diff --git a/src/components/fotos/SelfieSearchResultCard.tsx b/src/components/fotos/SelfieSearchResultCard.tsx new file mode 100644 index 00000000..a4530b55 --- /dev/null +++ b/src/components/fotos/SelfieSearchResultCard.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link' + +import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog' +import type { FaceSearchPhotoView } from '@/lib/faceSearch' + +import { ArchivePhotoCardBody } from './ArchivePhotoCard' +import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses' + +/** + * C234 — one photo of the selfie-search result (artefato: cena 05, variante + * revisada): the album's own card body (`ArchivePhotoCardBody`) minus the + * third-party line — the shape of the view model has no `people`, so "Quem + * aparece" cannot leak back in. The card links to the album's own detail + * (`/fotos?foto=`), the single owner of the photo overlay. + */ +export const SelfieSearchResultCard = ({ photo }: { photo: FaceSearchPhotoView }) => ( +
+ + + +
+) diff --git a/src/components/fotos/faceSearchEngine.ts b/src/components/fotos/faceSearchEngine.ts new file mode 100644 index 00000000..2e5ef551 --- /dev/null +++ b/src/components/fotos/faceSearchEngine.ts @@ -0,0 +1,159 @@ +/** + * C234 — the on-device engine of the selfie search. The visitor's photo never + * leaves the browser: it is decoded here, resized and run through the SAME + * face-api nets the CLI uses (same model ids, same detector options), and only + * the 128-float descriptor is ever sent. The engine is lazily imported — the + * ~2.6 MB of JS plus the ~7 MB of same-origin models are fetched only when the + * visitor actually starts the flow. + * + * Fail-closed probes come before the download: the bundled TFJS runs on the + * WebGL backend for speed, and a browser with WebGL disabled would silently + * crawl on the CPU backend (or blank) — the flow says so up front instead of + * blaming the selfie. The e2e build replaces the engine with a deterministic + * stub (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, `window.__faceSearchStub`), same + * contract as the card cutout. + */ +import { loadCardPhoto } from '@/components/cards/cardCanvas' +import { supportsWebgl } from '@/components/cards/cardCutout' +import { + FACE_DETECTOR_INPUT_SIZE, + FACE_DETECTOR_SCORE_THRESHOLD, + FACE_MODEL_DIR, + faceStubDescriptorFromBytes, +} from '@/lib/faceSearch' + +declare global { + interface Window { + /** Test seam: the e2e build stubs the engine through this flag. */ + __faceSearchStub?: 'ok' | 'slow' | 'error' + } +} + +const STUB_ENABLED = process.env.NEXT_PUBLIC_FACE_SEARCH_STUB === '1' +const STUB_DELAY_MS = 1500 + +/** Longest edge sent to the engine; the full-resolution photo never goes in. */ +const SELFIE_MAX_EDGE = 1024 + +export type SelfieDescriptorResult = + | { ok: true; descriptor: number[] } + | { ok: false; reason: 'unsupported' | 'engine' | 'empty' } + +/** + * Whether this browser can run the local engine. The caller must invoke it on + * the client (the probe needs `document`); the stub build is always "supported" + * so the e2e suite exercises the flow, not the capability. + */ +export const supportsFaceSearchEngine = (): boolean => { + if (STUB_ENABLED) return true + + return supportsWebgl() +} + +type FaceApi = typeof import('@vladmandic/face-api') + +let facadePromise: Promise | null = null + +/** + * Loads the three nets once per page: `tinyFaceDetector` finds the faces, + * `faceLandmark68Net` aligns them and `faceRecognitionNet` produces the 128-d + * descriptor — the exact trio the CLI loads. A failed load is not cached, so a + * retry re-attempts the download. + */ +const loadFaceApi = (): Promise => { + if (!facadePromise) { + facadePromise = (async () => { + const faceapi = await import('@vladmandic/face-api') + await Promise.all([ + faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_DIR), + faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_DIR), + faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_DIR), + ]) + return faceapi + })().catch((error: unknown) => { + facadePromise = null + throw error + }) + } + + return facadePromise +} + +const cappedSize = (width: number, height: number) => { + const longest = Math.max(width, height) + const scale = longest > SELFIE_MAX_EDGE ? SELFIE_MAX_EDGE / longest : 1 + + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +/** + * Descriptor of the LARGEST face in the photo (the person taking the selfie is + * the one closest to the camera). Resolves `empty` when no face is found — the + * flow has an honest state for that, never a generic engine error. + */ +export const computeSelfieDescriptor = async (file: File): Promise => { + if (STUB_ENABLED) return stubDescriptor(file) + + if (!supportsWebgl()) return { ok: false, reason: 'unsupported' } + + try { + const faceapi = await loadFaceApi() + const photo = await loadCardPhoto(file) + const { width, height } = cappedSize(photo.naturalWidth, photo.naturalHeight) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) return { ok: false, reason: 'engine' } + context.imageSmoothingEnabled = true + context.imageSmoothingQuality = 'high' + context.drawImage(photo, 0, 0, width, height) + + // Let the processing state paint before the synchronous inference. + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + + const results = await faceapi + .detectAllFaces( + canvas, + new faceapi.TinyFaceDetectorOptions({ + inputSize: FACE_DETECTOR_INPUT_SIZE, + scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD, + }), + ) + .withFaceLandmarks() + .withFaceDescriptors() + if (results.length === 0) return { ok: false, reason: 'empty' } + + const largest = results.reduce((best, current) => + current.detection.box.area > best.detection.box.area ? current : best, + ) + + return { ok: true, descriptor: Array.from(largest.descriptor) } + } catch { + return { ok: false, reason: 'engine' } + } +} + +const readStubMode = (): 'ok' | 'slow' | 'error' => { + if (typeof window === 'undefined') return 'ok' + const mode = window.__faceSearchStub + + return mode === 'slow' || mode === 'error' ? mode : 'ok' +} + +/** + * Test seam (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, e2e builds only): the same bytes + * always derive the same descriptor, so the spec seeds a subject whose vector + * equals the fixture's without ever loading a model. + */ +const stubDescriptor = async (file: File): Promise => { + const mode = readStubMode() + await new Promise((resolve) => setTimeout(resolve, mode === 'slow' ? STUB_DELAY_MS : 20)) + if (mode === 'error') return { ok: false, reason: 'engine' } + + const bytes = new Uint8Array(await file.arrayBuffer()) + return { ok: true, descriptor: faceStubDescriptorFromBytes(bytes) } +} diff --git a/src/globals/PhotoAlbum.ts b/src/globals/PhotoAlbum.ts index 0b7c5bd6..49e0c464 100644 --- a/src/globals/PhotoAlbum.ts +++ b/src/globals/PhotoAlbum.ts @@ -77,5 +77,15 @@ export const PhotoAlbum: GlobalConfig = { 'Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.', }, }, + { + name: 'selfieSearchEnabled', + type: 'checkbox', + label: 'Busca por selfie', + defaultValue: false, + admin: { + description: + 'Liga a busca por selfie (/fotos/encontre). Nasce desligada: só abra com o consentimento configurado e o aval jurídico/DPIA registrados (C234).', + }, + }, ], } diff --git a/src/lib/archivePhotoPublicCatalog.ts b/src/lib/archivePhotoPublicCatalog.ts index af62dbf7..727f6d04 100644 --- a/src/lib/archivePhotoPublicCatalog.ts +++ b/src/lib/archivePhotoPublicCatalog.ts @@ -25,6 +25,13 @@ import { normalizeForSearch } from '@/lib/speechSearch' export const ARCHIVE_PHOTO_ALBUM_PATH = '/fotos' +/** + * C234 — the selfie search entry (`/fotos/encontre`): a page of the album + * (noindex), gated by the album global's `selfieSearchEnabled` flag and by the + * Consent resolved server-side. + */ +export const ARCHIVE_PHOTO_ALBUM_ENTRY_PATH = `${ARCHIVE_PHOTO_ALBUM_PATH}/encontre` + /** Grid page size (design scene 07: 24 itens; prev/next preserve the facets). */ export const ARCHIVE_PHOTO_ALBUM_PAGE_SIZE = 24 diff --git a/src/lib/campaignConsentKeys.ts b/src/lib/campaignConsentKeys.ts index 3e660e4f..70c38e1f 100644 --- a/src/lib/campaignConsentKeys.ts +++ b/src/lib/campaignConsentKeys.ts @@ -29,6 +29,19 @@ export const CAMPAIGN_PUSH_CONSENT_KEY = 'campanha-notificacoes-push' 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. + */ +export const FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos' +export const FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice' + /** * S9 — campaign home "novidades" capture (name + WhatsApp, engagement level * toggle). Fail-closed like every public flow: while the admin has not created diff --git a/src/lib/faceSearch.ts b/src/lib/faceSearch.ts new file mode 100644 index 00000000..90adf161 --- /dev/null +++ b/src/lib/faceSearch.ts @@ -0,0 +1,188 @@ +/** + * C234 — pure contract of the selfie search over the public photo album. + * + * 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. + * + * 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. + */ +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. */ +export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet' + +/** + * Internal similarity threshold (face-api's usual 0.6 is a permissive default; + * the feature prefers missing a match over showing the wrong person). Never + * serialized: no caller of this contract receives a distance. + */ +export const FACE_SEARCH_MAX_DISTANCE = 0.45 + +/** Ceiling of photos in one answer; the oldest beyond it stay only in the album. */ +export const FACE_SEARCH_RESULT_LIMIT = 60 + +/** + * Detector tuning shared by the browser engine and the CLI engine: they must + * load the same three nets with the same options, otherwise the descriptors of + * the selfie and of the archive would live in different spaces. + */ +export const FACE_DETECTOR_INPUT_SIZE = 512 +export const FACE_DETECTOR_SCORE_THRESHOLD = 0.3 + +/** Same-origin directory the browser engine loads the model files from. */ +export const FACE_MODEL_DIR = '/fotos/modelos' + +export const FACE_SEARCH_INTENTS = ['search', 'leave-index'] as const + +/** + * A stored/requested descriptor: exactly `FACE_SEARCH_DESCRIPTOR_LENGTH` finite + * numbers, or nothing (fail-closed — a wrong-length or poisoned vector never + * reaches the math). + */ +export const readFaceVector = (value: unknown): number[] | null => { + if (!Array.isArray(value) || value.length !== FACE_SEARCH_DESCRIPTOR_LENGTH) return null + + const vector: number[] = [] + for (const component of value) { + if (typeof component !== 'number' || !Number.isFinite(component)) return null + vector.push(component) + } + 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 => { + if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY + + let sum = 0 + for (let index = 0; index < a.length; index += 1) { + const delta = a[index] - b[index] + sum += delta * delta + } + return Math.sqrt(sum) +} + +export type FaceSearchSubject = { + id: number + status?: string | null + model?: string | null + consentHash?: string | null + vector?: unknown +} + +/** + * 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). + */ +export const faceSubjectIsEligible = ( + subject: FaceSearchSubject, + { model, consentHash }: { model: string; consentHash: string }, +): boolean => + subject.status === 'active' && subject.model === model && subject.consentHash === consentHash + +/** + * 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 + } + } + + return best +} + +/** + * What one matched photo looks like to the visitor: the album's public view + * minus the third-party names (`people`/`peopleLabel`) and minus anything + * numeric — the shape is the guardrail. + */ +export type FaceSearchPhotoView = { + id: number + alt: string + title: string + takenOn: string | null + shortDateLabel: string | null + municipalityName: string | null + sceneLabel: string | null + metaLabel: string + thumbnailPath: string + mediaPath: string + downloadPath: string + downloadFilename: string +} + +export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchPhotoView => ({ + id: item.id, + alt: item.alt, + title: item.title, + takenOn: item.takenOn, + shortDateLabel: item.shortDateLabel, + municipalityName: item.municipalityName, + sceneLabel: item.sceneLabel, + metaLabel: item.metaLabel, + thumbnailPath: item.thumbnailPath, + mediaPath: item.mediaPath, + downloadPath: item.downloadPath, + downloadFilename: item.downloadFilename, +}) + +/** + * 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 + * loading a model. Never used outside the stub seam. + */ +export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => { + let hash = 0x811c9dc5 + for (const byte of bytes) { + hash ^= byte + hash = Math.imul(hash, 0x01000193) >>> 0 + } + + let state = hash || 0x9e3779b9 + const vector: number[] = [] + for (let index = 0; index < FACE_SEARCH_DESCRIPTOR_LENGTH; index += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + state >>>= 0 + vector.push((state / 0xffffffff) * 2 - 1) + } + return vector +} diff --git a/src/lib/schemas/faceSearch.ts b/src/lib/schemas/faceSearch.ts new file mode 100644 index 00000000..f4b72423 --- /dev/null +++ b/src/lib/schemas/faceSearch.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_INTENTS } from '@/lib/faceSearch' + +/** + * C234 — wire contract of the public `POST /api/fotos/selfie`. Strict object: + * an extra key is a malformed body, never a silently ignored one. The vector + * must be exactly the on-device descriptor (finite numbers — Zod rejects NaN + * and Infinity natively); the intent names the only two actions the endpoint + * performs. There is no image field and no identity field — by construction. + */ +export const faceSearchRequestSchema = z.strictObject({ + vector: z.array(z.number()).length(FACE_SEARCH_DESCRIPTOR_LENGTH), + intent: z.enum(FACE_SEARCH_INTENTS), +}) diff --git a/src/migrations/20260927_193954_add_face_subject.json b/src/migrations/20260927_193954_add_face_subject.json new file mode 100644 index 00000000..d24a4742 --- /dev/null +++ b/src/migrations/20260927_193954_add_face_subject.json @@ -0,0 +1,20790 @@ +{ + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users_roles": { + "name": "users_roles", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_users_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "users_roles_order_idx": { + "name": "users_roles_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_roles_parent_idx": { + "name": "users_roles_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_roles_parent_fk": { + "name": "users_roles_parent_fk", + "tableFrom": "users_roles", + "tableTo": "users", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users_sessions": { + "name": "users_sessions", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "users_sessions_order_idx": { + "name": "users_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_sessions_parent_id_idx": { + "name": "users_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_sessions_parent_id_fk": { + "name": "users_sessions_parent_id_fk", + "tableFrom": "users_sessions", + "tableTo": "users", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_updated_at_idx": { + "name": "users_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_user_sessions": { + "name": "campaign_user_sessions", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "campaign_user_sessions_order_idx": { + "name": "campaign_user_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_sessions_parent_id_idx": { + "name": "campaign_user_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_user_sessions_parent_id_fk": { + "name": "campaign_user_sessions_parent_id_fk", + "tableFrom": "campaign_user_sessions", + "tableTo": "campaign_user", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_user": { + "name": "campaign_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "avatar_id": { + "name": "avatar_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "enum_campaign_user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'leader'" + }, + "visibility": { + "name": "visibility", + "type": "enum_campaign_user_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'carteira'" + }, + "editing": { + "name": "editing", + "type": "enum_campaign_user_editing", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'carteira'" + }, + "phone": { + "name": "phone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "campaign_user_contact_idx": { + "name": "campaign_user_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_avatar_idx": { + "name": "campaign_user_avatar_idx", + "columns": [ + { + "expression": "avatar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_phone_idx": { + "name": "campaign_user_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_updated_at_idx": { + "name": "campaign_user_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_created_at_idx": { + "name": "campaign_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_email_idx": { + "name": "campaign_user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_username_idx": { + "name": "campaign_user_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_user_contact_id_contact_id_fk": { + "name": "campaign_user_contact_id_contact_id_fk", + "tableFrom": "campaign_user", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_user_avatar_id_media_id_fk": { + "name": "campaign_user_avatar_id_media_id_fk", + "tableFrom": "campaign_user", + "tableTo": "media", + "columnsFrom": [ + "avatar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_web_authn_credential": { + "name": "campaign_web_authn_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "device_label": { + "name": "device_label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_web_authn_credential_user_idx": { + "name": "campaign_web_authn_credential_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_web_authn_credential_credential_id_idx": { + "name": "campaign_web_authn_credential_credential_id_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_web_authn_credential_updated_at_idx": { + "name": "campaign_web_authn_credential_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_web_authn_credential_created_at_idx": { + "name": "campaign_web_authn_credential_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_web_authn_credential_user_id_campaign_user_id_fk": { + "name": "campaign_web_authn_credential_user_id_campaign_user_id_fk", + "tableFrom": "campaign_web_authn_credential", + "tableTo": "campaign_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "enum_notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_recipient_idx": { + "name": "notification_recipient_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_type_idx": { + "name": "notification_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_municipality_idx": { + "name": "notification_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_read_at_idx": { + "name": "notification_read_at_idx", + "columns": [ + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_updated_at_idx": { + "name": "notification_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_created_at_idx": { + "name": "notification_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_recipient_id_campaign_user_id_fk": { + "name": "notification_recipient_id_campaign_user_id_fk", + "tableFrom": "notification", + "tableTo": "campaign_user", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "notification_municipality_id_municipality_id_fk": { + "name": "notification_municipality_id_municipality_id_fk", + "tableFrom": "notification", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscription": { + "name": "push_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expiration_time": { + "name": "expiration_time", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "consent_content_hash": { + "name": "consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscription_user_idx": { + "name": "push_subscription_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_endpoint_idx": { + "name": "push_subscription_endpoint_idx", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_consent_idx": { + "name": "push_subscription_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_updated_at_idx": { + "name": "push_subscription_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_created_at_idx": { + "name": "push_subscription_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscription_user_id_campaign_user_id_fk": { + "name": "push_subscription_user_id_campaign_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "campaign_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "push_subscription_consent_id_consent_id_fk": { + "name": "push_subscription_consent_id_consent_id_fk", + "tableFrom": "push_subscription", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_invite": { + "name": "campaign_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_campaign_invite_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_invite_token_hash_idx": { + "name": "campaign_invite_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_leadership_idx": { + "name": "campaign_invite_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_kind_idx": { + "name": "campaign_invite_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_expires_at_idx": { + "name": "campaign_invite_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_used_at_idx": { + "name": "campaign_invite_used_at_idx", + "columns": [ + { + "expression": "used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_revoked_at_idx": { + "name": "campaign_invite_revoked_at_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_created_by_idx": { + "name": "campaign_invite_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_updated_at_idx": { + "name": "campaign_invite_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_created_at_idx": { + "name": "campaign_invite_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_kind_idx": { + "name": "leadership_kind_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_invite_leadership_id_leadership_id_fk": { + "name": "campaign_invite_leadership_id_leadership_id_fk", + "tableFrom": "campaign_invite", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_invite_created_by_id_campaign_user_id_fk": { + "name": "campaign_invite_created_by_id_campaign_user_id_fk", + "tableFrom": "campaign_invite", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_vote_summary_snapshot": { + "name": "campaign_vote_summary_snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'statewide'" + }, + "staff_vote_total_central": { + "name": "staff_vote_total_central", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_vote_summary_snapshot_day_idx": { + "name": "campaign_vote_summary_snapshot_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_vote_summary_snapshot_scope_key_idx": { + "name": "campaign_vote_summary_snapshot_scope_key_idx", + "columns": [ + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_vote_summary_snapshot_updated_at_idx": { + "name": "campaign_vote_summary_snapshot_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_vote_summary_snapshot_created_at_idx": { + "name": "campaign_vote_summary_snapshot_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "day_scopeKey_idx": { + "name": "day_scopeKey_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_strengths": { + "name": "municipality_strengths", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "municipality_strengths_order_idx": { + "name": "municipality_strengths_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_strengths_parent_id_idx": { + "name": "municipality_strengths_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_strengths_parent_id_fk": { + "name": "municipality_strengths_parent_id_fk", + "tableFrom": "municipality_strengths", + "tableTo": "municipality", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_risks": { + "name": "municipality_risks", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "municipality_risks_order_idx": { + "name": "municipality_risks_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_risks_parent_id_idx": { + "name": "municipality_risks_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_risks_parent_id_fk": { + "name": "municipality_risks_parent_id_fk", + "tableFrom": "municipality_risks", + "tableTo": "municipality", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality": { + "name": "municipality", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_municipality_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "ibge_code": { + "name": "ibge_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tse_city_code": { + "name": "tse_city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "enum_municipality_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'normal'" + }, + "engagement_level": { + "name": "engagement_level", + "type": "enum_municipality_engagement_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "level_note": { + "name": "level_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "level_changed_at": { + "name": "level_changed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_votes_pessimistic": { + "name": "expected_votes_pessimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expected_votes_central": { + "name": "expected_votes_central", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expected_votes_optimistic": { + "name": "expected_votes_optimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "political_trend_status": { + "name": "political_trend_status", + "type": "enum_municipality_political_trend_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "political_trend_note": { + "name": "political_trend_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "political_trend_recorded_by_id": { + "name": "political_trend_recorded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "political_trend_recorded_at": { + "name": "political_trend_recorded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "dobradinha_notes": { + "name": "dobradinha_notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "next_steps": { + "name": "next_steps", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "budget_notes": { + "name": "budget_notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_update_at": { + "name": "last_update_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "municipality_name_idx": { + "name": "municipality_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_slug_idx": { + "name": "municipality_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_kind_idx": { + "name": "municipality_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_city_idx": { + "name": "municipality_city_idx", + "columns": [ + { + "expression": "city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_region_idx": { + "name": "municipality_region_idx", + "columns": [ + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_ibge_code_idx": { + "name": "municipality_ibge_code_idx", + "columns": [ + { + "expression": "ibge_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_tse_city_code_idx": { + "name": "municipality_tse_city_code_idx", + "columns": [ + { + "expression": "tse_city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_zone_number_idx": { + "name": "municipality_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_priority_idx": { + "name": "municipality_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_engagement_level_idx": { + "name": "municipality_engagement_level_idx", + "columns": [ + { + "expression": "engagement_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_level_changed_at_idx": { + "name": "municipality_level_changed_at_idx", + "columns": [ + { + "expression": "level_changed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_political_trend_political_trend_recorded_by_idx": { + "name": "municipality_political_trend_political_trend_recorded_by_idx", + "columns": [ + { + "expression": "political_trend_recorded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_last_update_at_idx": { + "name": "municipality_last_update_at_idx", + "columns": [ + { + "expression": "last_update_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_updated_at_idx": { + "name": "municipality_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_created_at_idx": { + "name": "municipality_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_political_trend_recorded_by_id_campaign_user_id_fk": { + "name": "municipality_political_trend_recorded_by_id_campaign_user_id_fk", + "tableFrom": "municipality", + "tableTo": "campaign_user", + "columnsFrom": [ + "political_trend_recorded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_rels": { + "name": "municipality_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "municipality_rels_order_idx": { + "name": "municipality_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_parent_idx": { + "name": "municipality_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_path_idx": { + "name": "municipality_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_campaign_user_id_idx": { + "name": "municipality_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_state_deputy_id_idx": { + "name": "municipality_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_rels_parent_fk": { + "name": "municipality_rels_parent_fk", + "tableFrom": "municipality_rels", + "tableTo": "municipality", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "municipality_rels_campaign_user_fk": { + "name": "municipality_rels_campaign_user_fk", + "tableFrom": "municipality_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "municipality_rels_state_deputy_fk": { + "name": "municipality_rels_state_deputy_fk", + "tableFrom": "municipality_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leadership": { + "name": "leadership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "exclusive": { + "name": "exclusive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "support_status": { + "name": "support_status", + "type": "enum_leadership_support_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'a_abordar'" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_content_hash": { + "name": "consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leadership_contact_idx": { + "name": "leadership_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_exclusive_idx": { + "name": "leadership_exclusive_idx", + "columns": [ + { + "expression": "exclusive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_support_status_idx": { + "name": "leadership_support_status_idx", + "columns": [ + { + "expression": "support_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_user_idx": { + "name": "leadership_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_consent_idx": { + "name": "leadership_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_created_by_idx": { + "name": "leadership_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_updated_at_idx": { + "name": "leadership_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_created_at_idx": { + "name": "leadership_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leadership_contact_id_contact_id_fk": { + "name": "leadership_contact_id_contact_id_fk", + "tableFrom": "leadership", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "leadership_user_id_campaign_user_id_fk": { + "name": "leadership_user_id_campaign_user_id_fk", + "tableFrom": "leadership", + "tableTo": "campaign_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "leadership_consent_id_consent_id_fk": { + "name": "leadership_consent_id_consent_id_fk", + "tableFrom": "leadership", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "leadership_created_by_id_campaign_user_id_fk": { + "name": "leadership_created_by_id_campaign_user_id_fk", + "tableFrom": "leadership", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leadership_rels": { + "name": "leadership_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leadership_rels_order_idx": { + "name": "leadership_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_parent_idx": { + "name": "leadership_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_path_idx": { + "name": "leadership_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_municipality_id_idx": { + "name": "leadership_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_organization_id_idx": { + "name": "leadership_rels_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_state_deputy_id_idx": { + "name": "leadership_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_campaign_user_id_idx": { + "name": "leadership_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leadership_rels_parent_fk": { + "name": "leadership_rels_parent_fk", + "tableFrom": "leadership_rels", + "tableTo": "leadership", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_municipality_fk": { + "name": "leadership_rels_municipality_fk", + "tableFrom": "leadership_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_organization_fk": { + "name": "leadership_rels_organization_fk", + "tableFrom": "leadership_rels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_state_deputy_fk": { + "name": "leadership_rels_state_deputy_fk", + "tableFrom": "leadership_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_campaign_user_fk": { + "name": "leadership_rels_campaign_user_fk", + "tableFrom": "leadership_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_organization_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_name_idx": { + "name": "organization_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_slug_idx": { + "name": "organization_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_kind_idx": { + "name": "organization_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_created_by_idx": { + "name": "organization_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_updated_at_idx": { + "name": "organization_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_created_at_idx": { + "name": "organization_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_created_by_id_campaign_user_id_fk": { + "name": "organization_created_by_id_campaign_user_id_fk", + "tableFrom": "organization", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_rels": { + "name": "organization_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_rels_order_idx": { + "name": "organization_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_rels_parent_idx": { + "name": "organization_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_rels_path_idx": { + "name": "organization_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_rels_municipality_id_idx": { + "name": "organization_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_rels_parent_fk": { + "name": "organization_rels_parent_fk", + "tableFrom": "organization_rels", + "tableTo": "organization", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_rels_municipality_fk": { + "name": "organization_rels_municipality_fk", + "tableFrom": "organization_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.state_deputy": { + "name": "state_deputy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "ballot_name": { + "name": "ballot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "state_deputy_contact_idx": { + "name": "state_deputy_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_slug_idx": { + "name": "state_deputy_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_party_idx": { + "name": "state_deputy_party_idx", + "columns": [ + { + "expression": "party", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_created_by_idx": { + "name": "state_deputy_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_updated_at_idx": { + "name": "state_deputy_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_created_at_idx": { + "name": "state_deputy_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "state_deputy_contact_id_contact_id_fk": { + "name": "state_deputy_contact_id_contact_id_fk", + "tableFrom": "state_deputy", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "state_deputy_created_by_id_campaign_user_id_fk": { + "name": "state_deputy_created_by_id_campaign_user_id_fk", + "tableFrom": "state_deputy", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.state_deputy_rels": { + "name": "state_deputy_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "state_deputy_rels_order_idx": { + "name": "state_deputy_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_rels_parent_idx": { + "name": "state_deputy_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_rels_path_idx": { + "name": "state_deputy_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_rels_campaign_user_id_idx": { + "name": "state_deputy_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "state_deputy_rels_parent_fk": { + "name": "state_deputy_rels_parent_fk", + "tableFrom": "state_deputy_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "state_deputy_rels_campaign_user_fk": { + "name": "state_deputy_rels_campaign_user_fk", + "tableFrom": "state_deputy_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vote_pledge": { + "name": "vote_pledge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "declared_votes": { + "name": "declared_votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "declared_at": { + "name": "declared_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "declared_by_id": { + "name": "declared_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "estimated_votes_pessimistic": { + "name": "estimated_votes_pessimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "estimated_votes_central": { + "name": "estimated_votes_central", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "estimated_votes_optimistic": { + "name": "estimated_votes_optimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "estimate_note": { + "name": "estimate_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "estimated_by_id": { + "name": "estimated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "estimated_at": { + "name": "estimated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vote_pledge_leadership_idx": { + "name": "vote_pledge_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_municipality_idx": { + "name": "vote_pledge_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_declared_votes_idx": { + "name": "vote_pledge_declared_votes_idx", + "columns": [ + { + "expression": "declared_votes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_declared_by_idx": { + "name": "vote_pledge_declared_by_idx", + "columns": [ + { + "expression": "declared_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_estimated_by_idx": { + "name": "vote_pledge_estimated_by_idx", + "columns": [ + { + "expression": "estimated_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_updated_at_idx": { + "name": "vote_pledge_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_created_at_idx": { + "name": "vote_pledge_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_municipality_idx": { + "name": "leadership_municipality_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vote_pledge_leadership_id_leadership_id_fk": { + "name": "vote_pledge_leadership_id_leadership_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "vote_pledge_municipality_id_municipality_id_fk": { + "name": "vote_pledge_municipality_id_municipality_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "vote_pledge_declared_by_id_campaign_user_id_fk": { + "name": "vote_pledge_declared_by_id_campaign_user_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "campaign_user", + "columnsFrom": [ + "declared_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "vote_pledge_estimated_by_id_campaign_user_id_fk": { + "name": "vote_pledge_estimated_by_id_campaign_user_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "campaign_user", + "columnsFrom": [ + "estimated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._vote_pledge_v": { + "name": "_vote_pledge_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_leadership_id": { + "name": "version_leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_municipality_id": { + "name": "version_municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_declared_votes": { + "name": "version_declared_votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "version_declared_at": { + "name": "version_declared_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_declared_by_id": { + "name": "version_declared_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_estimated_votes_pessimistic": { + "name": "version_estimated_votes_pessimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "version_estimated_votes_central": { + "name": "version_estimated_votes_central", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "version_estimated_votes_optimistic": { + "name": "version_estimated_votes_optimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "version_estimate_note": { + "name": "version_estimate_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_estimated_by_id": { + "name": "version_estimated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_estimated_at": { + "name": "version_estimated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "_vote_pledge_v_parent_idx": { + "name": "_vote_pledge_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_leadership_idx": { + "name": "_vote_pledge_v_version_version_leadership_idx", + "columns": [ + { + "expression": "version_leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_municipality_idx": { + "name": "_vote_pledge_v_version_version_municipality_idx", + "columns": [ + { + "expression": "version_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_declared_votes_idx": { + "name": "_vote_pledge_v_version_version_declared_votes_idx", + "columns": [ + { + "expression": "version_declared_votes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_declared_by_idx": { + "name": "_vote_pledge_v_version_version_declared_by_idx", + "columns": [ + { + "expression": "version_declared_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_estimated_by_idx": { + "name": "_vote_pledge_v_version_version_estimated_by_idx", + "columns": [ + { + "expression": "version_estimated_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_updated_at_idx": { + "name": "_vote_pledge_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_created_at_idx": { + "name": "_vote_pledge_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_created_at_idx": { + "name": "_vote_pledge_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_updated_at_idx": { + "name": "_vote_pledge_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "version_leadership_version_municipality_idx": { + "name": "version_leadership_version_municipality_idx", + "columns": [ + { + "expression": "version_leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_vote_pledge_v_parent_id_vote_pledge_id_fk": { + "name": "_vote_pledge_v_parent_id_vote_pledge_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "vote_pledge", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_leadership_id_leadership_id_fk": { + "name": "_vote_pledge_v_version_leadership_id_leadership_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "leadership", + "columnsFrom": [ + "version_leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_municipality_id_municipality_id_fk": { + "name": "_vote_pledge_v_version_municipality_id_municipality_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "municipality", + "columnsFrom": [ + "version_municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_declared_by_id_campaign_user_id_fk": { + "name": "_vote_pledge_v_version_declared_by_id_campaign_user_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "campaign_user", + "columnsFrom": [ + "version_declared_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_estimated_by_id_campaign_user_id_fk": { + "name": "_vote_pledge_v_version_estimated_by_id_campaign_user_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "campaign_user", + "columnsFrom": [ + "version_estimated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.allocation_decision": { + "name": "allocation_decision", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pattern_id": { + "name": "pattern_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "enum_allocation_decision_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "alternative_reading": { + "name": "alternative_reading", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "allocation_decision_municipality_idx": { + "name": "allocation_decision_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_pattern_id_idx": { + "name": "allocation_decision_pattern_id_idx", + "columns": [ + { + "expression": "pattern_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_decided_by_idx": { + "name": "allocation_decision_decided_by_idx", + "columns": [ + { + "expression": "decided_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_updated_at_idx": { + "name": "allocation_decision_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_created_at_idx": { + "name": "allocation_decision_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "allocation_decision_municipality_id_municipality_id_fk": { + "name": "allocation_decision_municipality_id_municipality_id_fk", + "tableFrom": "allocation_decision", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "allocation_decision_decided_by_id_campaign_user_id_fk": { + "name": "allocation_decision_decided_by_id_campaign_user_id_fk", + "tableFrom": "allocation_decision", + "tableTo": "campaign_user", + "columnsFrom": [ + "decided_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_demand_status_history": { + "name": "campaign_demand_status_history", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "enum_campaign_demand_status_history_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "campaign_demand_status_history_order_idx": { + "name": "campaign_demand_status_history_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_status_history_parent_id_idx": { + "name": "campaign_demand_status_history_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_status_history_author_idx": { + "name": "campaign_demand_status_history_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_demand_status_history_author_id_campaign_user_id_fk": { + "name": "campaign_demand_status_history_author_id_campaign_user_id_fk", + "tableFrom": "campaign_demand_status_history", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_status_history_parent_id_fk": { + "name": "campaign_demand_status_history_parent_id_fk", + "tableFrom": "campaign_demand_status_history", + "tableTo": "campaign_demand", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_demand": { + "name": "campaign_demand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_campaign_demand_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "enum_campaign_demand_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'aberta'" + }, + "decision_note": { + "name": "decision_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_demand_title_idx": { + "name": "campaign_demand_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_slug_idx": { + "name": "campaign_demand_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_kind_idx": { + "name": "campaign_demand_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_municipality_idx": { + "name": "campaign_demand_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_activity_idx": { + "name": "campaign_demand_activity_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_leadership_idx": { + "name": "campaign_demand_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_status_idx": { + "name": "campaign_demand_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_decided_by_idx": { + "name": "campaign_demand_decided_by_idx", + "columns": [ + { + "expression": "decided_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_created_by_idx": { + "name": "campaign_demand_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_updated_at_idx": { + "name": "campaign_demand_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_created_at_idx": { + "name": "campaign_demand_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_demand_municipality_id_municipality_id_fk": { + "name": "campaign_demand_municipality_id_municipality_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_activity_id_activity_id_fk": { + "name": "campaign_demand_activity_id_activity_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "activity", + "columnsFrom": [ + "activity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_leadership_id_leadership_id_fk": { + "name": "campaign_demand_leadership_id_leadership_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_decided_by_id_campaign_user_id_fk": { + "name": "campaign_demand_decided_by_id_campaign_user_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "campaign_user", + "columnsFrom": [ + "decided_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_created_by_id_campaign_user_id_fk": { + "name": "campaign_demand_created_by_id_campaign_user_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_demand_rels": { + "name": "campaign_demand_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "campaign_demand_rels_order_idx": { + "name": "campaign_demand_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_parent_idx": { + "name": "campaign_demand_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_path_idx": { + "name": "campaign_demand_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_campaign_user_id_idx": { + "name": "campaign_demand_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_media_id_idx": { + "name": "campaign_demand_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_demand_rels_parent_fk": { + "name": "campaign_demand_rels_parent_fk", + "tableFrom": "campaign_demand_rels", + "tableTo": "campaign_demand", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_demand_rels_campaign_user_fk": { + "name": "campaign_demand_rels_campaign_user_fk", + "tableFrom": "campaign_demand_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_demand_rels_media_fk": { + "name": "campaign_demand_rels_media_fk", + "tableFrom": "campaign_demand_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supporter": { + "name": "supporter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_intention": { + "name": "vote_intention", + "type": "enum_supporter_vote_intention", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_content_hash": { + "name": "consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consent_id": { + "name": "vote_intention_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consent_content_hash": { + "name": "vote_intention_consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consented_at": { + "name": "vote_intention_consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "enum_supporter_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "consent_note": { + "name": "consent_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "supporter_contact_idx": { + "name": "supporter_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_municipality_idx": { + "name": "supporter_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_vote_intention_idx": { + "name": "supporter_vote_intention_idx", + "columns": [ + { + "expression": "vote_intention", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_consent_idx": { + "name": "supporter_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_vote_intention_consent_idx": { + "name": "supporter_vote_intention_consent_idx", + "columns": [ + { + "expression": "vote_intention_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_source_idx": { + "name": "supporter_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_created_by_idx": { + "name": "supporter_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_updated_at_idx": { + "name": "supporter_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_created_at_idx": { + "name": "supporter_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "supporter_contact_id_contact_id_fk": { + "name": "supporter_contact_id_contact_id_fk", + "tableFrom": "supporter", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_municipality_id_municipality_id_fk": { + "name": "supporter_municipality_id_municipality_id_fk", + "tableFrom": "supporter", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_consent_id_consent_id_fk": { + "name": "supporter_consent_id_consent_id_fk", + "tableFrom": "supporter", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_vote_intention_consent_id_consent_id_fk": { + "name": "supporter_vote_intention_consent_id_consent_id_fk", + "tableFrom": "supporter", + "tableTo": "consent", + "columnsFrom": [ + "vote_intention_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_created_by_id_campaign_user_id_fk": { + "name": "supporter_created_by_id_campaign_user_id_fk", + "tableFrom": "supporter", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supporter_import_batch": { + "name": "supporter_import_batch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "rows": { + "name": "rows", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "supporter_import_batch_batch_id_idx": { + "name": "supporter_import_batch_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_actor_idx": { + "name": "supporter_import_batch_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_expires_at_idx": { + "name": "supporter_import_batch_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_updated_at_idx": { + "name": "supporter_import_batch_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_created_at_idx": { + "name": "supporter_import_batch_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "supporter_import_batch_actor_id_campaign_user_id_fk": { + "name": "supporter_import_batch_actor_id_campaign_user_id_fk", + "tableFrom": "supporter_import_batch", + "tableTo": "campaign_user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_update_comments": { + "name": "municipality_update_comments", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "municipality_update_comments_order_idx": { + "name": "municipality_update_comments_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_comments_parent_id_idx": { + "name": "municipality_update_comments_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_comments_author_idx": { + "name": "municipality_update_comments_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_update_comments_author_id_campaign_user_id_fk": { + "name": "municipality_update_comments_author_id_campaign_user_id_fk", + "tableFrom": "municipality_update_comments", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "municipality_update_comments_parent_id_fk": { + "name": "municipality_update_comments_parent_id_fk", + "tableFrom": "municipality_update_comments", + "tableTo": "municipality_update", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_update": { + "name": "municipality_update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "polarity": { + "name": "polarity", + "type": "enum_municipality_update_polarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'neutra'" + }, + "urgent": { + "name": "urgent", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adversary_signal": { + "name": "adversary_signal", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "active_volunteers": { + "name": "active_volunteers", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "new_supports": { + "name": "new_supports", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "responsible_id": { + "name": "responsible_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "municipality_update_municipality_idx": { + "name": "municipality_update_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_author_idx": { + "name": "municipality_update_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_polarity_idx": { + "name": "municipality_update_polarity_idx", + "columns": [ + { + "expression": "polarity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_urgent_idx": { + "name": "municipality_update_urgent_idx", + "columns": [ + { + "expression": "urgent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_adversary_signal_idx": { + "name": "municipality_update_adversary_signal_idx", + "columns": [ + { + "expression": "adversary_signal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_responsible_idx": { + "name": "municipality_update_responsible_idx", + "columns": [ + { + "expression": "responsible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_resolved_by_idx": { + "name": "municipality_update_resolved_by_idx", + "columns": [ + { + "expression": "resolved_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_resolved_at_idx": { + "name": "municipality_update_resolved_at_idx", + "columns": [ + { + "expression": "resolved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_updated_at_idx": { + "name": "municipality_update_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_created_at_idx": { + "name": "municipality_update_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_update_municipality_id_municipality_id_fk": { + "name": "municipality_update_municipality_id_municipality_id_fk", + "tableFrom": "municipality_update", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "municipality_update_author_id_campaign_user_id_fk": { + "name": "municipality_update_author_id_campaign_user_id_fk", + "tableFrom": "municipality_update", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "municipality_update_responsible_id_campaign_user_id_fk": { + "name": "municipality_update_responsible_id_campaign_user_id_fk", + "tableFrom": "municipality_update", + "tableTo": "campaign_user", + "columnsFrom": [ + "responsible_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "municipality_update_resolved_by_id_campaign_user_id_fk": { + "name": "municipality_update_resolved_by_id_campaign_user_id_fk", + "tableFrom": "municipality_update", + "tableTo": "campaign_user", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_tasks": { + "name": "activity_tasks", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "responsible_id": { + "name": "responsible_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_tasks_order_idx": { + "name": "activity_tasks_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_tasks_parent_id_idx": { + "name": "activity_tasks_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_tasks_responsible_idx": { + "name": "activity_tasks_responsible_idx", + "columns": [ + { + "expression": "responsible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_tasks_responsible_id_contact_id_fk": { + "name": "activity_tasks_responsible_id_contact_id_fk", + "tableFrom": "activity_tasks", + "tableTo": "contact", + "columnsFrom": [ + "responsible_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_tasks_parent_id_fk": { + "name": "activity_tasks_parent_id_fk", + "tableFrom": "activity_tasks", + "tableTo": "activity", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_updates": { + "name": "activity_updates", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_updates_order_idx": { + "name": "activity_updates_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_updates_parent_id_idx": { + "name": "activity_updates_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_updates_author_idx": { + "name": "activity_updates_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_updates_author_id_campaign_user_id_fk": { + "name": "activity_updates_author_id_campaign_user_id_fk", + "tableFrom": "activity_updates", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_updates_parent_id_fk": { + "name": "activity_updates_parent_id_fk", + "tableFrom": "activity_updates", + "tableTo": "activity", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "enum_activity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'confirmado'" + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deputy_present": { + "name": "deputy_present", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "public_event": { + "name": "public_event", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "locality": { + "name": "locality", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "task_total": { + "name": "task_total", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "task_done_count": { + "name": "task_done_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "result_summary": { + "name": "result_summary", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "result_recorded_by_id": { + "name": "result_recorded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "result_recorded_at": { + "name": "result_recorded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_mirrored_change_at": { + "name": "last_mirrored_change_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "google_event_id": { + "name": "google_event_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "google_calendar_id": { + "name": "google_calendar_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_title_idx": { + "name": "activity_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_slug_idx": { + "name": "activity_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_status_idx": { + "name": "activity_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_deputy_present_idx": { + "name": "activity_deputy_present_idx", + "columns": [ + { + "expression": "deputy_present", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_all_day_idx": { + "name": "activity_all_day_idx", + "columns": [ + { + "expression": "all_day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_public_event_idx": { + "name": "activity_public_event_idx", + "columns": [ + { + "expression": "public_event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_start_at_idx": { + "name": "activity_start_at_idx", + "columns": [ + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_municipality_idx": { + "name": "activity_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_result_recorded_by_idx": { + "name": "activity_result_recorded_by_idx", + "columns": [ + { + "expression": "result_recorded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_google_event_id_idx": { + "name": "activity_google_event_id_idx", + "columns": [ + { + "expression": "google_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_created_by_idx": { + "name": "activity_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_updated_at_idx": { + "name": "activity_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_created_at_idx": { + "name": "activity_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_municipality_id_municipality_id_fk": { + "name": "activity_municipality_id_municipality_id_fk", + "tableFrom": "activity", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_result_recorded_by_id_campaign_user_id_fk": { + "name": "activity_result_recorded_by_id_campaign_user_id_fk", + "tableFrom": "activity", + "tableTo": "campaign_user", + "columnsFrom": [ + "result_recorded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_created_by_id_campaign_user_id_fk": { + "name": "activity_created_by_id_campaign_user_id_fk", + "tableFrom": "activity", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_texts": { + "name": "activity_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_texts_order_parent": { + "name": "activity_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_texts_text_idx": { + "name": "activity_texts_text_idx", + "columns": [ + { + "expression": "text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_texts_parent_fk": { + "name": "activity_texts_parent_fk", + "tableFrom": "activity_texts", + "tableTo": "activity", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_rels": { + "name": "activity_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_rels_order_idx": { + "name": "activity_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_parent_idx": { + "name": "activity_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_path_idx": { + "name": "activity_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_organization_id_idx": { + "name": "activity_rels_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_campaign_user_id_idx": { + "name": "activity_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_leadership_id_idx": { + "name": "activity_rels_leadership_id_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_state_deputy_id_idx": { + "name": "activity_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_media_id_idx": { + "name": "activity_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_rels_parent_fk": { + "name": "activity_rels_parent_fk", + "tableFrom": "activity_rels", + "tableTo": "activity", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_organization_fk": { + "name": "activity_rels_organization_fk", + "tableFrom": "activity_rels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_campaign_user_fk": { + "name": "activity_rels_campaign_user_fk", + "tableFrom": "activity_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_leadership_fk": { + "name": "activity_rels_leadership_fk", + "tableFrom": "activity_rels", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_state_deputy_fk": { + "name": "activity_rels_state_deputy_fk", + "tableFrom": "activity_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_media_fk": { + "name": "activity_rels_media_fk", + "tableFrom": "activity_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_topics": { + "name": "speech_topics", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_speech_topics", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "speech_topics_order_idx": { + "name": "speech_topics_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_topics_parent_idx": { + "name": "speech_topics_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_topics_value_idx": { + "name": "speech_topics_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_topics_parent_fk": { + "name": "speech_topics_parent_fk", + "tableFrom": "speech_topics", + "tableTo": "speech", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_scopes": { + "name": "speech_scopes", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_speech_scopes", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "speech_scopes_order_idx": { + "name": "speech_scopes_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_scopes_parent_idx": { + "name": "speech_scopes_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_scopes_value_idx": { + "name": "speech_scopes_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_scopes_parent_fk": { + "name": "speech_scopes_parent_fk", + "tableFrom": "speech_scopes", + "tableTo": "speech", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech": { + "name": "speech", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "enum_speech_origin", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'camara'" + }, + "speech_at": { + "name": "speech_at", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "legislature": { + "name": "legislature", + "type": "enum_speech_legislature", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "official_transcript": { + "name": "official_transcript", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "official_text_url": { + "name": "official_text_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "search_text": { + "name": "search_text", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "event_id": { + "name": "event_id", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "event_start_at": { + "name": "event_start_at", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "event_end_at": { + "name": "event_end_at", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "youtube_url": { + "name": "youtube_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "presiding_officer": { + "name": "presiding_officer", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "audio_id": { + "name": "audio_id", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "excerpt_t_ms": { + "name": "excerpt_t_ms", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "vod_playback_url": { + "name": "vod_playback_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vod_download_url": { + "name": "vod_download_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "enum_speech_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mirrored_media_id": { + "name": "mirrored_media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail_id": { + "name": "thumbnail_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "classified_by": { + "name": "classified_by", + "type": "enum_speech_classified_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'gazetteer'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "speech_source_key_idx": { + "name": "speech_source_key_idx", + "columns": [ + { + "expression": "source_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_origin_idx": { + "name": "speech_origin_idx", + "columns": [ + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_speech_at_idx": { + "name": "speech_speech_at_idx", + "columns": [ + { + "expression": "speech_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_year_idx": { + "name": "speech_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_legislature_idx": { + "name": "speech_legislature_idx", + "columns": [ + { + "expression": "legislature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_phase_idx": { + "name": "speech_phase_idx", + "columns": [ + { + "expression": "phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_event_id_idx": { + "name": "speech_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_audio_id_idx": { + "name": "speech_audio_id_idx", + "columns": [ + { + "expression": "audio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_excerpt_t_ms_idx": { + "name": "speech_excerpt_t_ms_idx", + "columns": [ + { + "expression": "excerpt_t_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_platform_idx": { + "name": "speech_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_mirrored_media_idx": { + "name": "speech_mirrored_media_idx", + "columns": [ + { + "expression": "mirrored_media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_thumbnail_idx": { + "name": "speech_thumbnail_idx", + "columns": [ + { + "expression": "thumbnail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_classified_by_idx": { + "name": "speech_classified_by_idx", + "columns": [ + { + "expression": "classified_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_updated_at_idx": { + "name": "speech_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_created_at_idx": { + "name": "speech_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_mirrored_media_id_internet_speech_media_id_fk": { + "name": "speech_mirrored_media_id_internet_speech_media_id_fk", + "tableFrom": "speech", + "tableTo": "internet_speech_media", + "columnsFrom": [ + "mirrored_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "speech_thumbnail_id_internet_speech_media_id_fk": { + "name": "speech_thumbnail_id_internet_speech_media_id_fk", + "tableFrom": "speech", + "tableTo": "internet_speech_media", + "columnsFrom": [ + "thumbnail_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_texts": { + "name": "speech_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "speech_texts_order_parent": { + "name": "speech_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_texts_parent_fk": { + "name": "speech_texts_parent_fk", + "tableFrom": "speech_texts", + "tableTo": "speech", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_rels": { + "name": "speech_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "speech_rels_order_idx": { + "name": "speech_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_rels_parent_idx": { + "name": "speech_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_rels_path_idx": { + "name": "speech_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_rels_municipality_id_idx": { + "name": "speech_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_rels_parent_fk": { + "name": "speech_rels_parent_fk", + "tableFrom": "speech_rels", + "tableTo": "speech", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "speech_rels_municipality_fk": { + "name": "speech_rels_municipality_fk", + "tableFrom": "speech_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_segment": { + "name": "speech_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "speech_id": { + "name": "speech_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "start_seconds": { + "name": "start_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "end_seconds": { + "name": "end_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "speech_segment_speech_idx": { + "name": "speech_segment_speech_idx", + "columns": [ + { + "expression": "speech_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_segment_updated_at_idx": { + "name": "speech_segment_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_segment_created_at_idx": { + "name": "speech_segment_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_segment_speech_id_speech_id_fk": { + "name": "speech_segment_speech_id_speech_id_fk", + "tableFrom": "speech_segment", + "tableTo": "speech", + "columnsFrom": [ + "speech_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_cut": { + "name": "speech_cut", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "speech_id": { + "name": "speech_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "start_seconds": { + "name": "start_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "end_seconds": { + "name": "end_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "enum_speech_cut_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "step": { + "name": "step", + "type": "enum_speech_cut_step", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "speech_cut_speech_idx": { + "name": "speech_cut_speech_idx", + "columns": [ + { + "expression": "speech_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_cut_status_idx": { + "name": "speech_cut_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_cut_media_idx": { + "name": "speech_cut_media_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_cut_created_by_idx": { + "name": "speech_cut_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_cut_updated_at_idx": { + "name": "speech_cut_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_cut_created_at_idx": { + "name": "speech_cut_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_cut_speech_id_speech_id_fk": { + "name": "speech_cut_speech_id_speech_id_fk", + "tableFrom": "speech_cut", + "tableTo": "speech", + "columnsFrom": [ + "speech_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "speech_cut_media_id_media_id_fk": { + "name": "speech_cut_media_id_media_id_fk", + "tableFrom": "speech_cut", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "speech_cut_created_by_id_campaign_user_id_fk": { + "name": "speech_cut_created_by_id_campaign_user_id_fk", + "tableFrom": "speech_cut", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_embedding": { + "name": "speech_embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "speech_id": { + "name": "speech_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_speech_embedding_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "start_seconds": { + "name": "start_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "dimensions": { + "name": "dimensions", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "vector": { + "name": "vector", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "speech_embedding_speech_idx": { + "name": "speech_embedding_speech_idx", + "columns": [ + { + "expression": "speech_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_embedding_kind_idx": { + "name": "speech_embedding_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_embedding_updated_at_idx": { + "name": "speech_embedding_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "speech_embedding_created_at_idx": { + "name": "speech_embedding_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "speech_embedding_speech_id_speech_id_fk": { + "name": "speech_embedding_speech_id_speech_id_fk", + "tableFrom": "speech_embedding", + "tableTo": "speech", + "columnsFrom": [ + "speech_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.internet_speech_media": { + "name": "internet_speech_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "internet_speech_media_updated_at_idx": { + "name": "internet_speech_media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "internet_speech_media_created_at_idx": { + "name": "internet_speech_media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "internet_speech_media_filename_idx": { + "name": "internet_speech_media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reel": { + "name": "reel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "enum_reel_feature", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "enum_reel_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "video_id": { + "name": "video_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_with_audio_id": { + "name": "video_with_audio_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "narration_audio_id": { + "name": "narration_audio_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "captions_id": { + "name": "captions_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cover_id": { + "name": "cover_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reel_source_hash_idx": { + "name": "reel_source_hash_idx", + "columns": [ + { + "expression": "source_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_status_idx": { + "name": "reel_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_video_idx": { + "name": "reel_video_idx", + "columns": [ + { + "expression": "video_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_video_with_audio_idx": { + "name": "reel_video_with_audio_idx", + "columns": [ + { + "expression": "video_with_audio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_narration_audio_idx": { + "name": "reel_narration_audio_idx", + "columns": [ + { + "expression": "narration_audio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_captions_idx": { + "name": "reel_captions_idx", + "columns": [ + { + "expression": "captions_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_cover_idx": { + "name": "reel_cover_idx", + "columns": [ + { + "expression": "cover_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_created_by_idx": { + "name": "reel_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_updated_at_idx": { + "name": "reel_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_created_at_idx": { + "name": "reel_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reel_video_id_reel_media_id_fk": { + "name": "reel_video_id_reel_media_id_fk", + "tableFrom": "reel", + "tableTo": "reel_media", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reel_video_with_audio_id_reel_media_id_fk": { + "name": "reel_video_with_audio_id_reel_media_id_fk", + "tableFrom": "reel", + "tableTo": "reel_media", + "columnsFrom": [ + "video_with_audio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reel_narration_audio_id_reel_media_id_fk": { + "name": "reel_narration_audio_id_reel_media_id_fk", + "tableFrom": "reel", + "tableTo": "reel_media", + "columnsFrom": [ + "narration_audio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reel_captions_id_reel_media_id_fk": { + "name": "reel_captions_id_reel_media_id_fk", + "tableFrom": "reel", + "tableTo": "reel_media", + "columnsFrom": [ + "captions_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reel_cover_id_reel_media_id_fk": { + "name": "reel_cover_id_reel_media_id_fk", + "tableFrom": "reel", + "tableTo": "reel_media", + "columnsFrom": [ + "cover_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reel_created_by_id_campaign_user_id_fk": { + "name": "reel_created_by_id_campaign_user_id_fk", + "tableFrom": "reel", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reel_media": { + "name": "reel_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reel_media_updated_at_idx": { + "name": "reel_media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_media_created_at_idx": { + "name": "reel_media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reel_media_filename_idx": { + "name": "reel_media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_piece_topics": { + "name": "content_piece_topics", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_content_piece_topics", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "content_piece_topics_order_idx": { + "name": "content_piece_topics_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_topics_parent_idx": { + "name": "content_piece_topics_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_topics_value_idx": { + "name": "content_piece_topics_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_piece_topics_parent_fk": { + "name": "content_piece_topics_parent_fk", + "tableFrom": "content_piece_topics", + "tableTo": "content_piece", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_piece_curated_fields": { + "name": "content_piece_curated_fields", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_content_piece_curated_fields", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "content_piece_curated_fields_order_idx": { + "name": "content_piece_curated_fields_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_curated_fields_parent_idx": { + "name": "content_piece_curated_fields_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_piece_curated_fields_parent_fk": { + "name": "content_piece_curated_fields_parent_fk", + "tableFrom": "content_piece_curated_fields", + "tableTo": "content_piece", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_piece": { + "name": "content_piece", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "enum_content_piece_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city_label": { + "name": "city_label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "institution": { + "name": "institution", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "piece_date": { + "name": "piece_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "enum_content_piece_origin", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'arquivo'" + }, + "status": { + "name": "status", + "type": "enum_content_piece_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rascunho'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "enum_content_piece_processing_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pronto'" + }, + "step": { + "name": "step", + "type": "enum_content_piece_step", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "link_failure_reason": { + "name": "link_failure_reason", + "type": "enum_content_piece_link_failure_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "search_text": { + "name": "search_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "content_piece_slug_idx": { + "name": "content_piece_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_type_idx": { + "name": "content_piece_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_municipality_idx": { + "name": "content_piece_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_piece_date_idx": { + "name": "content_piece_piece_date_idx", + "columns": [ + { + "expression": "piece_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_media_idx": { + "name": "content_piece_media_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_source_url_idx": { + "name": "content_piece_source_url_idx", + "columns": [ + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_status_idx": { + "name": "content_piece_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_processing_status_idx": { + "name": "content_piece_processing_status_idx", + "columns": [ + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_created_by_idx": { + "name": "content_piece_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_updated_at_idx": { + "name": "content_piece_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_created_at_idx": { + "name": "content_piece_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_piece_municipality_id_municipality_id_fk": { + "name": "content_piece_municipality_id_municipality_id_fk", + "tableFrom": "content_piece", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "content_piece_media_id_content_media_id_fk": { + "name": "content_piece_media_id_content_media_id_fk", + "tableFrom": "content_piece", + "tableTo": "content_media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "content_piece_created_by_id_campaign_user_id_fk": { + "name": "content_piece_created_by_id_campaign_user_id_fk", + "tableFrom": "content_piece", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_piece_texts": { + "name": "content_piece_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "content_piece_texts_order_parent": { + "name": "content_piece_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_piece_texts_parent_fk": { + "name": "content_piece_texts_parent_fk", + "tableFrom": "content_piece_texts", + "tableTo": "content_piece", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_piece_rels": { + "name": "content_piece_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "content_piece_rels_order_idx": { + "name": "content_piece_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_rels_parent_idx": { + "name": "content_piece_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_rels_path_idx": { + "name": "content_piece_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_piece_rels_leadership_id_idx": { + "name": "content_piece_rels_leadership_id_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_piece_rels_parent_fk": { + "name": "content_piece_rels_parent_fk", + "tableFrom": "content_piece_rels", + "tableTo": "content_piece", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_piece_rels_leadership_fk": { + "name": "content_piece_rels_leadership_fk", + "tableFrom": "content_piece_rels", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_media": { + "name": "content_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "content_media_updated_at_idx": { + "name": "content_media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_media_created_at_idx": { + "name": "content_media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_media_filename_idx": { + "name": "content_media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_photo_catalog_themes": { + "name": "archive_photo_catalog_themes", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_archive_photo_catalog_themes", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "archive_photo_catalog_themes_order_idx": { + "name": "archive_photo_catalog_themes_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_catalog_themes_parent_idx": { + "name": "archive_photo_catalog_themes_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_catalog_themes_value_idx": { + "name": "archive_photo_catalog_themes_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_photo_catalog_themes_parent_fk": { + "name": "archive_photo_catalog_themes_parent_fk", + "tableFrom": "archive_photo_catalog_themes", + "tableTo": "archive_photo", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_photo_curated_fields": { + "name": "archive_photo_curated_fields", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_archive_photo_curated_fields", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "archive_photo_curated_fields_order_idx": { + "name": "archive_photo_curated_fields_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_curated_fields_parent_idx": { + "name": "archive_photo_curated_fields_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_photo_curated_fields_parent_fk": { + "name": "archive_photo_curated_fields_parent_fk", + "tableFrom": "archive_photo_curated_fields", + "tableTo": "archive_photo", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_photo_tags": { + "name": "archive_photo_tags", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "archive_photo_tags_order_idx": { + "name": "archive_photo_tags_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_tags_parent_id_idx": { + "name": "archive_photo_tags_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_photo_tags_parent_id_fk": { + "name": "archive_photo_tags_parent_id_fk", + "tableFrom": "archive_photo_tags", + "tableTo": "archive_photo", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_photo_albums": { + "name": "archive_photo_albums", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "archive_photo_albums_order_idx": { + "name": "archive_photo_albums_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_albums_parent_id_idx": { + "name": "archive_photo_albums_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_photo_albums_parent_id_fk": { + "name": "archive_photo_albums_parent_id_fk", + "tableFrom": "archive_photo_albums", + "tableTo": "archive_photo", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_photo": { + "name": "archive_photo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flickr_id": { + "name": "flickr_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "catalog_caption": { + "name": "catalog_caption", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "catalog_description": { + "name": "catalog_description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "catalog_scene": { + "name": "catalog_scene", + "type": "enum_archive_photo_catalog_scene", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "catalog_visible_text": { + "name": "catalog_visible_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "catalog_has_people": { + "name": "catalog_has_people", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "catalog_municipality_id": { + "name": "catalog_municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "catalog_source": { + "name": "catalog_source", + "type": "enum_archive_photo_catalog_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "catalog_cataloged_at": { + "name": "catalog_cataloged_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "publication_status": { + "name": "publication_status", + "type": "enum_archive_photo_publication_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "taken_on": { + "name": "taken_on", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "geo_latitude": { + "name": "geo_latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "geo_longitude": { + "name": "geo_longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "exif": { + "name": "exif", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "license": { + "name": "license", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "municipality_name": { + "name": "municipality_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "municipality_slug": { + "name": "municipality_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "search_text": { + "name": "search_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "faces_checked_at": { + "name": "faces_checked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "faces_checked_key": { + "name": "faces_checked_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "archive_photo_flickr_id_idx": { + "name": "archive_photo_flickr_id_idx", + "columns": [ + { + "expression": "flickr_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_catalog_catalog_scene_idx": { + "name": "archive_photo_catalog_catalog_scene_idx", + "columns": [ + { + "expression": "catalog_scene", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_catalog_catalog_municipality_idx": { + "name": "archive_photo_catalog_catalog_municipality_idx", + "columns": [ + { + "expression": "catalog_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_catalog_catalog_cataloged_at_idx": { + "name": "archive_photo_catalog_catalog_cataloged_at_idx", + "columns": [ + { + "expression": "catalog_cataloged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_publication_status_idx": { + "name": "archive_photo_publication_status_idx", + "columns": [ + { + "expression": "publication_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_taken_on_idx": { + "name": "archive_photo_taken_on_idx", + "columns": [ + { + "expression": "taken_on", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_municipality_slug_idx": { + "name": "archive_photo_municipality_slug_idx", + "columns": [ + { + "expression": "municipality_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_updated_at_idx": { + "name": "archive_photo_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_created_at_idx": { + "name": "archive_photo_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "archive_photo_filename_idx": { + "name": "archive_photo_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_photo_catalog_municipality_id_municipality_id_fk": { + "name": "archive_photo_catalog_municipality_id_municipality_id_fk", + "tableFrom": "archive_photo", + "tableTo": "municipality", + "columnsFrom": [ + "catalog_municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_photo_texts": { + "name": "archive_photo_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "archive_photo_texts_order_parent": { + "name": "archive_photo_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_photo_texts_parent_fk": { + "name": "archive_photo_texts_parent_fk", + "tableFrom": "archive_photo_texts", + "tableTo": "archive_photo", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.face_subject": { + "name": "face_subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "consent_hash": { + "name": "consent_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "vector": { + "name": "vector", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "enum_face_subject_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "face_subject_consent_idx": { + "name": "face_subject_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "face_subject_status_idx": { + "name": "face_subject_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "face_subject_updated_at_idx": { + "name": "face_subject_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "face_subject_created_at_idx": { + "name": "face_subject_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "face_subject_consent_id_consent_id_fk": { + "name": "face_subject_consent_id_consent_id_fk", + "tableFrom": "face_subject", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.face_subject_rels": { + "name": "face_subject_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "archive_photo_id": { + "name": "archive_photo_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "face_subject_rels_order_idx": { + "name": "face_subject_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "face_subject_rels_parent_idx": { + "name": "face_subject_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "face_subject_rels_path_idx": { + "name": "face_subject_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "face_subject_rels_archive_photo_id_idx": { + "name": "face_subject_rels_archive_photo_id_idx", + "columns": [ + { + "expression": "archive_photo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "face_subject_rels_parent_fk": { + "name": "face_subject_rels_parent_fk", + "tableFrom": "face_subject_rels", + "tableTo": "face_subject", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "face_subject_rels_archive_photo_fk": { + "name": "face_subject_rels_archive_photo_fk", + "tableFrom": "face_subject_rels", + "tableTo": "archive_photo", + "columnsFrom": [ + "archive_photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_event": { + "name": "content_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "enum_content_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "enum_content_event_subject_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "variant": { + "name": "variant", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "content_event_updated_at_idx": { + "name": "content_event_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_event_created_at_idx": { + "name": "content_event_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subjectType_subjectId_type_idx": { + "name": "subjectType_subjectId_type_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_topics": { + "name": "recording_topics", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_recording_topics", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "recording_topics_order_idx": { + "name": "recording_topics_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_topics_parent_idx": { + "name": "recording_topics_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_topics_value_idx": { + "name": "recording_topics_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_topics_parent_fk": { + "name": "recording_topics_parent_fk", + "tableFrom": "recording_topics", + "tableTo": "recording", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_scopes": { + "name": "recording_scopes", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_recording_scopes", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "recording_scopes_order_idx": { + "name": "recording_scopes_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_scopes_parent_idx": { + "name": "recording_scopes_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_scopes_value_idx": { + "name": "recording_scopes_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_scopes_parent_fk": { + "name": "recording_scopes_parent_fk", + "tableFrom": "recording_scopes", + "tableTo": "recording", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_speaker_labels": { + "name": "recording_speaker_labels", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "speaker_key": { + "name": "speaker_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recording_speaker_labels_order_idx": { + "name": "recording_speaker_labels_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_speaker_labels_parent_id_idx": { + "name": "recording_speaker_labels_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_speaker_labels_parent_id_fk": { + "name": "recording_speaker_labels_parent_id_fk", + "tableFrom": "recording_speaker_labels", + "tableTo": "recording", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording": { + "name": "recording", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "enum_recording_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "step": { + "name": "step", + "type": "enum_recording_step", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "search_text": { + "name": "search_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "classified_by": { + "name": "classified_by", + "type": "enum_recording_classified_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "speaker_labels_dropped": { + "name": "speaker_labels_dropped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recording_recorded_at_idx": { + "name": "recording_recorded_at_idx", + "columns": [ + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_year_idx": { + "name": "recording_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_status_idx": { + "name": "recording_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_media_idx": { + "name": "recording_media_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_classified_by_idx": { + "name": "recording_classified_by_idx", + "columns": [ + { + "expression": "classified_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_created_by_idx": { + "name": "recording_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_updated_at_idx": { + "name": "recording_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_created_at_idx": { + "name": "recording_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_media_id_recording_media_id_fk": { + "name": "recording_media_id_recording_media_id_fk", + "tableFrom": "recording", + "tableTo": "recording_media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "recording_created_by_id_campaign_user_id_fk": { + "name": "recording_created_by_id_campaign_user_id_fk", + "tableFrom": "recording", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_texts": { + "name": "recording_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recording_texts_order_parent": { + "name": "recording_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_texts_parent_fk": { + "name": "recording_texts_parent_fk", + "tableFrom": "recording_texts", + "tableTo": "recording", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_rels": { + "name": "recording_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recording_rels_order_idx": { + "name": "recording_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_rels_parent_idx": { + "name": "recording_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_rels_path_idx": { + "name": "recording_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_rels_municipality_id_idx": { + "name": "recording_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_rels_parent_fk": { + "name": "recording_rels_parent_fk", + "tableFrom": "recording_rels", + "tableTo": "recording", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recording_rels_municipality_fk": { + "name": "recording_rels_municipality_fk", + "tableFrom": "recording_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_media": { + "name": "recording_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recording_media_updated_at_idx": { + "name": "recording_media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_media_created_at_idx": { + "name": "recording_media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_media_filename_idx": { + "name": "recording_media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recording_segment": { + "name": "recording_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recording_id": { + "name": "recording_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "speaker_key": { + "name": "speaker_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "start_seconds": { + "name": "start_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "end_seconds": { + "name": "end_seconds", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recording_segment_recording_idx": { + "name": "recording_segment_recording_idx", + "columns": [ + { + "expression": "recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_segment_updated_at_idx": { + "name": "recording_segment_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recording_segment_created_at_idx": { + "name": "recording_segment_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recording_segment_recording_id_recording_id_fk": { + "name": "recording_segment_recording_id_recording_id_fk", + "tableFrom": "recording_segment", + "tableTo": "recording", + "columnsFrom": [ + "recording_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_feed": { + "name": "calendar_feed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "secret_slug": { + "name": "secret_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "filter_municipality_id": { + "name": "filter_municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_deputy_present": { + "name": "filter_deputy_present", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "filter_tag": { + "name": "filter_tag", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_feed_secret_slug_idx": { + "name": "calendar_feed_secret_slug_idx", + "columns": [ + { + "expression": "secret_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_filter_municipality_idx": { + "name": "calendar_feed_filter_municipality_idx", + "columns": [ + { + "expression": "filter_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_revoked_at_idx": { + "name": "calendar_feed_revoked_at_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_created_by_idx": { + "name": "calendar_feed_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_updated_at_idx": { + "name": "calendar_feed_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_created_at_idx": { + "name": "calendar_feed_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_feed_filter_municipality_id_municipality_id_fk": { + "name": "calendar_feed_filter_municipality_id_municipality_id_fk", + "tableFrom": "calendar_feed", + "tableTo": "municipality", + "columnsFrom": [ + "filter_municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_feed_created_by_id_campaign_user_id_fk": { + "name": "calendar_feed_created_by_id_campaign_user_id_fk", + "tableFrom": "calendar_feed", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_calendar_sync": { + "name": "google_calendar_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "oauth_refresh_token": { + "name": "oauth_refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "oauth_connected_at": { + "name": "oauth_connected_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "oauth_error_at": { + "name": "oauth_error_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "oauth_error": { + "name": "oauth_error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "push_channel_id": { + "name": "push_channel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "push_channel_resource_id": { + "name": "push_channel_resource_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "push_channel_expires_at": { + "name": "push_channel_expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "push_channel_secret": { + "name": "push_channel_secret", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "push_channel_error": { + "name": "push_channel_error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_seen_event_ids": { + "name": "last_seen_event_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "google_calendar_sync_calendar_id_idx": { + "name": "google_calendar_sync_calendar_id_idx", + "columns": [ + { + "expression": "calendar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_sync_disabled_at_idx": { + "name": "google_calendar_sync_disabled_at_idx", + "columns": [ + { + "expression": "disabled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_sync_updated_at_idx": { + "name": "google_calendar_sync_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_sync_created_at_idx": { + "name": "google_calendar_sync_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_tally": { + "name": "election_tally", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_tally_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_tally_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "city_code": { + "name": "city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "city_name": { + "name": "city_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "aptos": { + "name": "aptos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "comparecimento": { + "name": "comparecimento", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "abstencoes": { + "name": "abstencoes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_validos": { + "name": "votos_validos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_nominais_validos": { + "name": "votos_nominais_validos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_legenda": { + "name": "votos_legenda", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_branco": { + "name": "votos_branco", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_nulo": { + "name": "votos_nulo", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_anulados": { + "name": "votos_anulados", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "winner_candidate_number": { + "name": "winner_candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "winner_candidate_name": { + "name": "winner_candidate_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "winner_votes": { + "name": "winner_votes", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "winner_coalition": { + "name": "winner_coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "winner_party": { + "name": "winner_party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_tally_year_idx": { + "name": "election_tally_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_office_idx": { + "name": "election_tally_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_turn_idx": { + "name": "election_tally_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_state_idx": { + "name": "election_tally_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_city_code_idx": { + "name": "election_tally_city_code_idx", + "columns": [ + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_city_name_idx": { + "name": "election_tally_city_name_idx", + "columns": [ + { + "expression": "city_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_zone_number_idx": { + "name": "election_tally_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_updated_at_idx": { + "name": "election_tally_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_created_at_idx": { + "name": "election_tally_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "year_office_turn_state_cityCode_zoneNumber_idx": { + "name": "year_office_turn_state_cityCode_zoneNumber_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_candidate_vote": { + "name": "election_candidate_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_candidate_vote_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_candidate_vote_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "city_code": { + "name": "city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "city_name": { + "name": "city_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "candidate_number": { + "name": "candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "candidate_name": { + "name": "candidate_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "coalition": { + "name": "coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vote_type": { + "name": "vote_type", + "type": "enum_election_candidate_vote_vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nominal'" + }, + "votes": { + "name": "votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_candidate_vote_year_idx": { + "name": "election_candidate_vote_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_office_idx": { + "name": "election_candidate_vote_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_turn_idx": { + "name": "election_candidate_vote_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_state_idx": { + "name": "election_candidate_vote_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_city_code_idx": { + "name": "election_candidate_vote_city_code_idx", + "columns": [ + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_city_name_idx": { + "name": "election_candidate_vote_city_name_idx", + "columns": [ + { + "expression": "city_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_zone_number_idx": { + "name": "election_candidate_vote_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_candidate_number_idx": { + "name": "election_candidate_vote_candidate_number_idx", + "columns": [ + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_vote_type_idx": { + "name": "election_candidate_vote_vote_type_idx", + "columns": [ + { + "expression": "vote_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_updated_at_idx": { + "name": "election_candidate_vote_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_created_at_idx": { + "name": "election_candidate_vote_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compound_index_idx": { + "name": "compound_index_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vote_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_candidate": { + "name": "election_candidate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_candidate_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_candidate_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "candidate_number": { + "name": "candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "urna_name": { + "name": "urna_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "complete_name": { + "name": "complete_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "coalition": { + "name": "coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "candidate_status": { + "name": "candidate_status", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "elected": { + "name": "elected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "elected_by": { + "name": "elected_by", + "type": "enum_election_candidate_elected_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "total_votes_state": { + "name": "total_votes_state", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "identity_key": { + "name": "identity_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "running_again2026": { + "name": "running_again2026", + "type": "enum_election_candidate_running_again2026", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'desconhecido'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_candidate_year_idx": { + "name": "election_candidate_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_office_idx": { + "name": "election_candidate_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_turn_idx": { + "name": "election_candidate_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_state_idx": { + "name": "election_candidate_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_candidate_number_idx": { + "name": "election_candidate_candidate_number_idx", + "columns": [ + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_elected_idx": { + "name": "election_candidate_elected_idx", + "columns": [ + { + "expression": "elected", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_identity_key_idx": { + "name": "election_candidate_identity_key_idx", + "columns": [ + { + "expression": "identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_updated_at_idx": { + "name": "election_candidate_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_created_at_idx": { + "name": "election_candidate_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "year_office_turn_state_candidateNumber_idx": { + "name": "year_office_turn_state_candidateNumber_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "media_updated_at_idx": { + "name": "media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_filename_idx": { + "name": "media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.petition": { + "name": "petition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "form_title": { + "name": "form_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "form_subtitle": { + "name": "form_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "form_consent_id": { + "name": "form_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tracking_facebook_pixel_id": { + "name": "tracking_facebook_pixel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_petition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "petition_form_form_consent_idx": { + "name": "petition_form_form_consent_idx", + "columns": [ + { + "expression": "form_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition_updated_at_idx": { + "name": "petition_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition_created_at_idx": { + "name": "petition_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition__status_idx": { + "name": "petition__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "petition_form_consent_id_consent_id_fk": { + "name": "petition_form_consent_id_consent_id_fk", + "tableFrom": "petition", + "tableTo": "consent", + "columnsFrom": [ + "form_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._petition_v": { + "name": "_petition_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_subtitle": { + "name": "version_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_enabled": { + "name": "version_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "version_body": { + "name": "version_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_form_title": { + "name": "version_form_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_form_subtitle": { + "name": "version_form_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_form_consent_id": { + "name": "version_form_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_tracking_facebook_pixel_id": { + "name": "version_tracking_facebook_pixel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__petition_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_petition_v_parent_idx": { + "name": "_petition_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_form_version_form_consent_idx": { + "name": "_petition_v_version_form_version_form_consent_idx", + "columns": [ + { + "expression": "version_form_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version_updated_at_idx": { + "name": "_petition_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version_created_at_idx": { + "name": "_petition_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version__status_idx": { + "name": "_petition_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_created_at_idx": { + "name": "_petition_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_updated_at_idx": { + "name": "_petition_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_latest_idx": { + "name": "_petition_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_petition_v_parent_id_petition_id_fk": { + "name": "_petition_v_parent_id_petition_id_fk", + "tableFrom": "_petition_v", + "tableTo": "petition", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_petition_v_version_form_consent_id_consent_id_fk": { + "name": "_petition_v_version_form_consent_id_consent_id_fk", + "tableFrom": "_petition_v", + "tableTo": "consent", + "columnsFrom": [ + "version_form_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_phones": { + "name": "contact_phones", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contact_phones_order_idx": { + "name": "contact_phones_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_phones_parent_id_idx": { + "name": "contact_phones_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_phones_value_idx": { + "name": "contact_phones_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_phones_parent_id_fk": { + "name": "contact_phones_parent_id_fk", + "tableFrom": "contact_phones", + "tableTo": "contact", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact": { + "name": "contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "enum_contact_gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "enum_contact_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_updated_at_idx": { + "name": "contact_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_created_at_idx": { + "name": "contact_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.consent": { + "name": "consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "consent_key_idx": { + "name": "consent_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consent_updated_at_idx": { + "name": "consent_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consent_created_at_idx": { + "name": "consent_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signature": { + "name": "signature", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "petition_id": { + "name": "petition_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signature_contact_idx": { + "name": "signature_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_petition_idx": { + "name": "signature_petition_idx", + "columns": [ + { + "expression": "petition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_consent_idx": { + "name": "signature_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_updated_at_idx": { + "name": "signature_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_created_at_idx": { + "name": "signature_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signature_contact_id_contact_id_fk": { + "name": "signature_contact_id_contact_id_fk", + "tableFrom": "signature", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signature_petition_id_petition_id_fk": { + "name": "signature_petition_id_petition_id_fk", + "tableFrom": "signature", + "tableTo": "petition", + "columnsFrom": [ + "petition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signature_consent_id_consent_id_fk": { + "name": "signature_consent_id_consent_id_fk", + "tableFrom": "signature", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "campaign_level": { + "name": "campaign_level", + "type": "enum_subscription_campaign_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_contact_idx": { + "name": "subscription_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_consent_idx": { + "name": "subscription_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_updated_at_idx": { + "name": "subscription_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_created_at_idx": { + "name": "subscription_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_contact_id_contact_id_fk": { + "name": "subscription_contact_id_contact_id_fk", + "tableFrom": "subscription", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscription_consent_id_consent_id_fk": { + "name": "subscription_consent_id_consent_id_fk", + "tableFrom": "subscription", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "enum_post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_date": { + "name": "published_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "post_slug_idx": { + "name": "post_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_category_idx": { + "name": "post_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_cover_image_idx": { + "name": "post_cover_image_idx", + "columns": [ + { + "expression": "cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_updated_at_idx": { + "name": "post_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_created_at_idx": { + "name": "post_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post__status_idx": { + "name": "post__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_category_id_tag_id_fk": { + "name": "post_category_id_tag_id_fk", + "tableFrom": "post", + "tableTo": "tag", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_image_id_media_id_fk": { + "name": "post_cover_image_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_rels": { + "name": "post_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "post_rels_order_idx": { + "name": "post_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_parent_idx": { + "name": "post_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_path_idx": { + "name": "post_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_tag_id_idx": { + "name": "post_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_rels_parent_fk": { + "name": "post_rels_parent_fk", + "tableFrom": "post_rels", + "tableTo": "post", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_rels_tag_fk": { + "name": "post_rels_tag_fk", + "tableFrom": "post_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._post_v": { + "name": "_post_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_slug": { + "name": "version_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_type": { + "name": "version_type", + "type": "enum__post_v_version_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "version_category_id": { + "name": "version_category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_subtitle": { + "name": "version_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_cover_image_id": { + "name": "version_cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_published_date": { + "name": "version_published_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_body": { + "name": "version_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__post_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_post_v_parent_idx": { + "name": "_post_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_slug_idx": { + "name": "_post_v_version_version_slug_idx", + "columns": [ + { + "expression": "version_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_category_idx": { + "name": "_post_v_version_version_category_idx", + "columns": [ + { + "expression": "version_category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_cover_image_idx": { + "name": "_post_v_version_version_cover_image_idx", + "columns": [ + { + "expression": "version_cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_updated_at_idx": { + "name": "_post_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_created_at_idx": { + "name": "_post_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version__status_idx": { + "name": "_post_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_created_at_idx": { + "name": "_post_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_updated_at_idx": { + "name": "_post_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_latest_idx": { + "name": "_post_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_post_v_parent_id_post_id_fk": { + "name": "_post_v_parent_id_post_id_fk", + "tableFrom": "_post_v", + "tableTo": "post", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_post_v_version_category_id_tag_id_fk": { + "name": "_post_v_version_category_id_tag_id_fk", + "tableFrom": "_post_v", + "tableTo": "tag", + "columnsFrom": [ + "version_category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_post_v_version_cover_image_id_media_id_fk": { + "name": "_post_v_version_cover_image_id_media_id_fk", + "tableFrom": "_post_v", + "tableTo": "media", + "columnsFrom": [ + "version_cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._post_v_rels": { + "name": "_post_v_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_post_v_rels_order_idx": { + "name": "_post_v_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_parent_idx": { + "name": "_post_v_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_path_idx": { + "name": "_post_v_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_tag_id_idx": { + "name": "_post_v_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_post_v_rels_parent_fk": { + "name": "_post_v_rels_parent_fk", + "tableFrom": "_post_v_rels", + "tableTo": "_post_v", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "_post_v_rels_tag_fk": { + "name": "_post_v_rels_tag_fk", + "tableFrom": "_post_v_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_slug_idx": { + "name": "tag_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_updated_at_idx": { + "name": "tag_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_created_at_idx": { + "name": "tag_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_link_destinations": { + "name": "share_link_destinations", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "live": { + "name": "live", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "share_link_destinations_order_idx": { + "name": "share_link_destinations_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "share_link_destinations_parent_id_idx": { + "name": "share_link_destinations_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_link_destinations_parent_id_fk": { + "name": "share_link_destinations_parent_id_fk", + "tableFrom": "share_link_destinations", + "tableTo": "share_link", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_link": { + "name": "share_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "enum_share_link_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "share_link_slug_idx": { + "name": "share_link_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "share_link_image_idx": { + "name": "share_link_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "share_link_updated_at_idx": { + "name": "share_link_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "share_link_created_at_idx": { + "name": "share_link_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_link_image_id_media_id_fk": { + "name": "share_link_image_id_media_id_fk", + "tableFrom": "share_link", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jingle": { + "name": "jingle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audio_id": { + "name": "audio_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jingle_slug_idx": { + "name": "jingle_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jingle_cover_image_idx": { + "name": "jingle_cover_image_idx", + "columns": [ + { + "expression": "cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jingle_audio_idx": { + "name": "jingle_audio_idx", + "columns": [ + { + "expression": "audio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jingle_updated_at_idx": { + "name": "jingle_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jingle_created_at_idx": { + "name": "jingle_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jingle_cover_image_id_media_id_fk": { + "name": "jingle_cover_image_id_media_id_fk", + "tableFrom": "jingle", + "tableTo": "media", + "columnsFrom": [ + "cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "jingle_audio_id_media_id_fk": { + "name": "jingle_audio_id_media_id_fk", + "tableFrom": "jingle", + "tableTo": "media", + "columnsFrom": [ + "audio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exports": { + "name": "exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "enum_exports_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'csv'" + }, + "limit": { + "name": "limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "sort": { + "name": "sort", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "enum_exports_sort_order", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "drafts": { + "name": "drafts", + "type": "enum_exports_drafts", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'yes'" + }, + "collection_slug": { + "name": "collection_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'signature'" + }, + "where": { + "name": "where", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exports_updated_at_idx": { + "name": "exports_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exports_created_at_idx": { + "name": "exports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exports_filename_idx": { + "name": "exports_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exports_texts": { + "name": "exports_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exports_texts_order_parent": { + "name": "exports_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exports_texts_parent_fk": { + "name": "exports_texts_parent_fk", + "tableFrom": "exports_texts", + "tableTo": "exports", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imports": { + "name": "imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "collection_slug": { + "name": "collection_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "import_mode": { + "name": "import_mode", + "type": "enum_imports_import_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "match_field": { + "name": "match_field", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'id'" + }, + "status": { + "name": "status", + "type": "enum_imports_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "summary_imported": { + "name": "summary_imported", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_updated": { + "name": "summary_updated", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_total": { + "name": "summary_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_issues": { + "name": "summary_issues", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_issue_details": { + "name": "summary_issue_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "imports_updated_at_idx": { + "name": "imports_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "imports_created_at_idx": { + "name": "imports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "imports_filename_idx": { + "name": "imports_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_kv": { + "name": "payload_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payload_kv_key_idx": { + "name": "payload_kv_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_jobs_log": { + "name": "payload_jobs_log", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "task_slug": { + "name": "task_slug", + "type": "enum_payload_jobs_log_task_slug", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "task_i_d": { + "name": "task_i_d", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "enum_payload_jobs_log_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_jobs_log_order_idx": { + "name": "payload_jobs_log_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_log_parent_id_idx": { + "name": "payload_jobs_log_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_jobs_log_parent_id_fk": { + "name": "payload_jobs_log_parent_id_fk", + "tableFrom": "payload_jobs_log", + "tableTo": "payload_jobs", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_jobs": { + "name": "payload_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "total_tried": { + "name": "total_tried", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "task_slug": { + "name": "task_slug", + "type": "enum_payload_jobs_task_slug", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "queue": { + "name": "queue", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "processing": { + "name": "processing", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_jobs_completed_at_idx": { + "name": "payload_jobs_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_total_tried_idx": { + "name": "payload_jobs_total_tried_idx", + "columns": [ + { + "expression": "total_tried", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_has_error_idx": { + "name": "payload_jobs_has_error_idx", + "columns": [ + { + "expression": "has_error", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_task_slug_idx": { + "name": "payload_jobs_task_slug_idx", + "columns": [ + { + "expression": "task_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_queue_idx": { + "name": "payload_jobs_queue_idx", + "columns": [ + { + "expression": "queue", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_wait_until_idx": { + "name": "payload_jobs_wait_until_idx", + "columns": [ + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_processing_idx": { + "name": "payload_jobs_processing_idx", + "columns": [ + { + "expression": "processing", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_updated_at_idx": { + "name": "payload_jobs_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_created_at_idx": { + "name": "payload_jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents": { + "name": "payload_locked_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "global_slug": { + "name": "global_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_locked_documents_global_slug_idx": { + "name": "payload_locked_documents_global_slug_idx", + "columns": [ + { + "expression": "global_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_updated_at_idx": { + "name": "payload_locked_documents_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_created_at_idx": { + "name": "payload_locked_documents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents_rels": { + "name": "payload_locked_documents_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_web_authn_credential_id": { + "name": "campaign_web_authn_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "push_subscription_id": { + "name": "push_subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_invite_id": { + "name": "campaign_invite_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_vote_summary_snapshot_id": { + "name": "campaign_vote_summary_snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_pledge_id": { + "name": "vote_pledge_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allocation_decision_id": { + "name": "allocation_decision_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_demand_id": { + "name": "campaign_demand_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supporter_id": { + "name": "supporter_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supporter_import_batch_id": { + "name": "supporter_import_batch_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "municipality_update_id": { + "name": "municipality_update_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "speech_id": { + "name": "speech_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "speech_segment_id": { + "name": "speech_segment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "speech_cut_id": { + "name": "speech_cut_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "speech_embedding_id": { + "name": "speech_embedding_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "internet_speech_media_id": { + "name": "internet_speech_media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reel_id": { + "name": "reel_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reel_media_id": { + "name": "reel_media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_piece_id": { + "name": "content_piece_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_media_id": { + "name": "content_media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "archive_photo_id": { + "name": "archive_photo_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "face_subject_id": { + "name": "face_subject_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_event_id": { + "name": "content_event_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recording_id": { + "name": "recording_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recording_media_id": { + "name": "recording_media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recording_segment_id": { + "name": "recording_segment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "calendar_feed_id": { + "name": "calendar_feed_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "google_calendar_sync_id": { + "name": "google_calendar_sync_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_tally_id": { + "name": "election_tally_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_candidate_vote_id": { + "name": "election_candidate_vote_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_candidate_id": { + "name": "election_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "petition_id": { + "name": "petition_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signature_id": { + "name": "signature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "share_link_id": { + "name": "share_link_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "jingle_id": { + "name": "jingle_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_locked_documents_rels_order_idx": { + "name": "payload_locked_documents_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_parent_idx": { + "name": "payload_locked_documents_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_path_idx": { + "name": "payload_locked_documents_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_users_id_idx": { + "name": "payload_locked_documents_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_user_id_idx": { + "name": "payload_locked_documents_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_web_authn_credent_idx": { + "name": "payload_locked_documents_rels_campaign_web_authn_credent_idx", + "columns": [ + { + "expression": "campaign_web_authn_credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_notification_id_idx": { + "name": "payload_locked_documents_rels_notification_id_idx", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_push_subscription_id_idx": { + "name": "payload_locked_documents_rels_push_subscription_id_idx", + "columns": [ + { + "expression": "push_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_invite_id_idx": { + "name": "payload_locked_documents_rels_campaign_invite_id_idx", + "columns": [ + { + "expression": "campaign_invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_vote_summary_snap_idx": { + "name": "payload_locked_documents_rels_campaign_vote_summary_snap_idx", + "columns": [ + { + "expression": "campaign_vote_summary_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_municipality_id_idx": { + "name": "payload_locked_documents_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_leadership_id_idx": { + "name": "payload_locked_documents_rels_leadership_id_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_organization_id_idx": { + "name": "payload_locked_documents_rels_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_state_deputy_id_idx": { + "name": "payload_locked_documents_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_vote_pledge_id_idx": { + "name": "payload_locked_documents_rels_vote_pledge_id_idx", + "columns": [ + { + "expression": "vote_pledge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_allocation_decision_id_idx": { + "name": "payload_locked_documents_rels_allocation_decision_id_idx", + "columns": [ + { + "expression": "allocation_decision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_demand_id_idx": { + "name": "payload_locked_documents_rels_campaign_demand_id_idx", + "columns": [ + { + "expression": "campaign_demand_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_supporter_id_idx": { + "name": "payload_locked_documents_rels_supporter_id_idx", + "columns": [ + { + "expression": "supporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_supporter_import_batch_id_idx": { + "name": "payload_locked_documents_rels_supporter_import_batch_id_idx", + "columns": [ + { + "expression": "supporter_import_batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_municipality_update_id_idx": { + "name": "payload_locked_documents_rels_municipality_update_id_idx", + "columns": [ + { + "expression": "municipality_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_activity_id_idx": { + "name": "payload_locked_documents_rels_activity_id_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_speech_id_idx": { + "name": "payload_locked_documents_rels_speech_id_idx", + "columns": [ + { + "expression": "speech_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_speech_segment_id_idx": { + "name": "payload_locked_documents_rels_speech_segment_id_idx", + "columns": [ + { + "expression": "speech_segment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_speech_cut_id_idx": { + "name": "payload_locked_documents_rels_speech_cut_id_idx", + "columns": [ + { + "expression": "speech_cut_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_speech_embedding_id_idx": { + "name": "payload_locked_documents_rels_speech_embedding_id_idx", + "columns": [ + { + "expression": "speech_embedding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_internet_speech_media_id_idx": { + "name": "payload_locked_documents_rels_internet_speech_media_id_idx", + "columns": [ + { + "expression": "internet_speech_media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_reel_id_idx": { + "name": "payload_locked_documents_rels_reel_id_idx", + "columns": [ + { + "expression": "reel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_reel_media_id_idx": { + "name": "payload_locked_documents_rels_reel_media_id_idx", + "columns": [ + { + "expression": "reel_media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_content_piece_id_idx": { + "name": "payload_locked_documents_rels_content_piece_id_idx", + "columns": [ + { + "expression": "content_piece_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_content_media_id_idx": { + "name": "payload_locked_documents_rels_content_media_id_idx", + "columns": [ + { + "expression": "content_media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_archive_photo_id_idx": { + "name": "payload_locked_documents_rels_archive_photo_id_idx", + "columns": [ + { + "expression": "archive_photo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_face_subject_id_idx": { + "name": "payload_locked_documents_rels_face_subject_id_idx", + "columns": [ + { + "expression": "face_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_content_event_id_idx": { + "name": "payload_locked_documents_rels_content_event_id_idx", + "columns": [ + { + "expression": "content_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_recording_id_idx": { + "name": "payload_locked_documents_rels_recording_id_idx", + "columns": [ + { + "expression": "recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_recording_media_id_idx": { + "name": "payload_locked_documents_rels_recording_media_id_idx", + "columns": [ + { + "expression": "recording_media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_recording_segment_id_idx": { + "name": "payload_locked_documents_rels_recording_segment_id_idx", + "columns": [ + { + "expression": "recording_segment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_calendar_feed_id_idx": { + "name": "payload_locked_documents_rels_calendar_feed_id_idx", + "columns": [ + { + "expression": "calendar_feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_google_calendar_sync_id_idx": { + "name": "payload_locked_documents_rels_google_calendar_sync_id_idx", + "columns": [ + { + "expression": "google_calendar_sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_tally_id_idx": { + "name": "payload_locked_documents_rels_election_tally_id_idx", + "columns": [ + { + "expression": "election_tally_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_candidate_vote_id_idx": { + "name": "payload_locked_documents_rels_election_candidate_vote_id_idx", + "columns": [ + { + "expression": "election_candidate_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_candidate_id_idx": { + "name": "payload_locked_documents_rels_election_candidate_id_idx", + "columns": [ + { + "expression": "election_candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_media_id_idx": { + "name": "payload_locked_documents_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_petition_id_idx": { + "name": "payload_locked_documents_rels_petition_id_idx", + "columns": [ + { + "expression": "petition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_contact_id_idx": { + "name": "payload_locked_documents_rels_contact_id_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_consent_id_idx": { + "name": "payload_locked_documents_rels_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_signature_id_idx": { + "name": "payload_locked_documents_rels_signature_id_idx", + "columns": [ + { + "expression": "signature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_subscription_id_idx": { + "name": "payload_locked_documents_rels_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_post_id_idx": { + "name": "payload_locked_documents_rels_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_tag_id_idx": { + "name": "payload_locked_documents_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_share_link_id_idx": { + "name": "payload_locked_documents_rels_share_link_id_idx", + "columns": [ + { + "expression": "share_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_jingle_id_idx": { + "name": "payload_locked_documents_rels_jingle_id_idx", + "columns": [ + { + "expression": "jingle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_locked_documents_rels_parent_fk": { + "name": "payload_locked_documents_rels_parent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "payload_locked_documents", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_users_fk": { + "name": "payload_locked_documents_rels_users_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_user_fk": { + "name": "payload_locked_documents_rels_campaign_user_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_web_authn_credenti_fk": { + "name": "payload_locked_documents_rels_campaign_web_authn_credenti_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_web_authn_credential", + "columnsFrom": [ + "campaign_web_authn_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_notification_fk": { + "name": "payload_locked_documents_rels_notification_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_push_subscription_fk": { + "name": "payload_locked_documents_rels_push_subscription_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "push_subscription", + "columnsFrom": [ + "push_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_invite_fk": { + "name": "payload_locked_documents_rels_campaign_invite_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_invite", + "columnsFrom": [ + "campaign_invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_vote_summary_snaps_fk": { + "name": "payload_locked_documents_rels_campaign_vote_summary_snaps_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_vote_summary_snapshot", + "columnsFrom": [ + "campaign_vote_summary_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_municipality_fk": { + "name": "payload_locked_documents_rels_municipality_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_leadership_fk": { + "name": "payload_locked_documents_rels_leadership_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_organization_fk": { + "name": "payload_locked_documents_rels_organization_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_state_deputy_fk": { + "name": "payload_locked_documents_rels_state_deputy_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_vote_pledge_fk": { + "name": "payload_locked_documents_rels_vote_pledge_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "vote_pledge", + "columnsFrom": [ + "vote_pledge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_allocation_decision_fk": { + "name": "payload_locked_documents_rels_allocation_decision_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "allocation_decision", + "columnsFrom": [ + "allocation_decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_demand_fk": { + "name": "payload_locked_documents_rels_campaign_demand_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_demand", + "columnsFrom": [ + "campaign_demand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_supporter_fk": { + "name": "payload_locked_documents_rels_supporter_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "supporter", + "columnsFrom": [ + "supporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_supporter_import_batch_fk": { + "name": "payload_locked_documents_rels_supporter_import_batch_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "supporter_import_batch", + "columnsFrom": [ + "supporter_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_municipality_update_fk": { + "name": "payload_locked_documents_rels_municipality_update_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "municipality_update", + "columnsFrom": [ + "municipality_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_activity_fk": { + "name": "payload_locked_documents_rels_activity_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "activity", + "columnsFrom": [ + "activity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_speech_fk": { + "name": "payload_locked_documents_rels_speech_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "speech", + "columnsFrom": [ + "speech_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_speech_segment_fk": { + "name": "payload_locked_documents_rels_speech_segment_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "speech_segment", + "columnsFrom": [ + "speech_segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_speech_cut_fk": { + "name": "payload_locked_documents_rels_speech_cut_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "speech_cut", + "columnsFrom": [ + "speech_cut_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_speech_embedding_fk": { + "name": "payload_locked_documents_rels_speech_embedding_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "speech_embedding", + "columnsFrom": [ + "speech_embedding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_internet_speech_media_fk": { + "name": "payload_locked_documents_rels_internet_speech_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "internet_speech_media", + "columnsFrom": [ + "internet_speech_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_reel_fk": { + "name": "payload_locked_documents_rels_reel_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "reel", + "columnsFrom": [ + "reel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_reel_media_fk": { + "name": "payload_locked_documents_rels_reel_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "reel_media", + "columnsFrom": [ + "reel_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_content_piece_fk": { + "name": "payload_locked_documents_rels_content_piece_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "content_piece", + "columnsFrom": [ + "content_piece_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_content_media_fk": { + "name": "payload_locked_documents_rels_content_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "content_media", + "columnsFrom": [ + "content_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_archive_photo_fk": { + "name": "payload_locked_documents_rels_archive_photo_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "archive_photo", + "columnsFrom": [ + "archive_photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_face_subject_fk": { + "name": "payload_locked_documents_rels_face_subject_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "face_subject", + "columnsFrom": [ + "face_subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_content_event_fk": { + "name": "payload_locked_documents_rels_content_event_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "content_event", + "columnsFrom": [ + "content_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_recording_fk": { + "name": "payload_locked_documents_rels_recording_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "recording", + "columnsFrom": [ + "recording_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_recording_media_fk": { + "name": "payload_locked_documents_rels_recording_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "recording_media", + "columnsFrom": [ + "recording_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_recording_segment_fk": { + "name": "payload_locked_documents_rels_recording_segment_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "recording_segment", + "columnsFrom": [ + "recording_segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_calendar_feed_fk": { + "name": "payload_locked_documents_rels_calendar_feed_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "calendar_feed", + "columnsFrom": [ + "calendar_feed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_google_calendar_sync_fk": { + "name": "payload_locked_documents_rels_google_calendar_sync_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "google_calendar_sync", + "columnsFrom": [ + "google_calendar_sync_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_tally_fk": { + "name": "payload_locked_documents_rels_election_tally_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_tally", + "columnsFrom": [ + "election_tally_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_candidate_vote_fk": { + "name": "payload_locked_documents_rels_election_candidate_vote_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_candidate_vote", + "columnsFrom": [ + "election_candidate_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_candidate_fk": { + "name": "payload_locked_documents_rels_election_candidate_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_candidate", + "columnsFrom": [ + "election_candidate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_media_fk": { + "name": "payload_locked_documents_rels_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_petition_fk": { + "name": "payload_locked_documents_rels_petition_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "petition", + "columnsFrom": [ + "petition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_contact_fk": { + "name": "payload_locked_documents_rels_contact_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_consent_fk": { + "name": "payload_locked_documents_rels_consent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_signature_fk": { + "name": "payload_locked_documents_rels_signature_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "signature", + "columnsFrom": [ + "signature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_subscription_fk": { + "name": "payload_locked_documents_rels_subscription_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_post_fk": { + "name": "payload_locked_documents_rels_post_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_tag_fk": { + "name": "payload_locked_documents_rels_tag_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_share_link_fk": { + "name": "payload_locked_documents_rels_share_link_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "share_link", + "columnsFrom": [ + "share_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_jingle_fk": { + "name": "payload_locked_documents_rels_jingle_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "jingle", + "columnsFrom": [ + "jingle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences": { + "name": "payload_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_preferences_key_idx": { + "name": "payload_preferences_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_updated_at_idx": { + "name": "payload_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_created_at_idx": { + "name": "payload_preferences_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences_rels": { + "name": "payload_preferences_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_preferences_rels_order_idx": { + "name": "payload_preferences_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_parent_idx": { + "name": "payload_preferences_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_path_idx": { + "name": "payload_preferences_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_users_id_idx": { + "name": "payload_preferences_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_campaign_user_id_idx": { + "name": "payload_preferences_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_preferences_rels_parent_fk": { + "name": "payload_preferences_rels_parent_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "payload_preferences", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_users_fk": { + "name": "payload_preferences_rels_users_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_campaign_user_fk": { + "name": "payload_preferences_rels_campaign_user_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_migrations": { + "name": "payload_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_migrations_updated_at_idx": { + "name": "payload_migrations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_migrations_created_at_idx": { + "name": "payload_migrations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings_social_links": { + "name": "site_settings_social_links", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum_site_settings_social_links_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "site_settings_social_links_order_idx": { + "name": "site_settings_social_links_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "site_settings_social_links_parent_id_idx": { + "name": "site_settings_social_links_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "site_settings_social_links_parent_id_fk": { + "name": "site_settings_social_links_parent_id_fk", + "tableFrom": "site_settings_social_links", + "tableTo": "site_settings", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "header_title": { + "name": "header_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "tracking_facebook_pixel_id": { + "name": "tracking_facebook_pixel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "_status": { + "name": "_status", + "type": "enum_site_settings_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "site_settings__status_idx": { + "name": "site_settings__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._site_settings_v_version_social_links": { + "name": "_site_settings_v_version_social_links", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum__site_settings_v_version_social_links_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "_uuid": { + "name": "_uuid", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_site_settings_v_version_social_links_order_idx": { + "name": "_site_settings_v_version_social_links_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_version_social_links_parent_id_idx": { + "name": "_site_settings_v_version_social_links_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_site_settings_v_version_social_links_parent_id_fk": { + "name": "_site_settings_v_version_social_links_parent_id_fk", + "tableFrom": "_site_settings_v_version_social_links", + "tableTo": "_site_settings_v", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._site_settings_v": { + "name": "_site_settings_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version_header_title": { + "name": "version_header_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_tracking_facebook_pixel_id": { + "name": "version_tracking_facebook_pixel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__site_settings_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "autosave": { + "name": "autosave", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_site_settings_v_version_version__status_idx": { + "name": "_site_settings_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_created_at_idx": { + "name": "_site_settings_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_updated_at_idx": { + "name": "_site_settings_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_latest_idx": { + "name": "_site_settings_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_autosave_idx": { + "name": "_site_settings_v_autosave_idx", + "columns": [ + { + "expression": "autosave", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.home": { + "name": "home", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "home_image_idx": { + "name": "home_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "home_image_id_media_id_fk": { + "name": "home_image_id_media_id_fk", + "tableFrom": "home", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata_keywords": { + "name": "metadata_keywords", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "metadata_keywords_order_idx": { + "name": "metadata_keywords_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "metadata_keywords_parent_id_idx": { + "name": "metadata_keywords_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_keywords_parent_id_fk": { + "name": "metadata_keywords_parent_id_fk", + "tableFrom": "metadata_keywords", + "tableTo": "metadata", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "open_graph_site_name": { + "name": "open_graph_site_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "open_graph_description": { + "name": "open_graph_description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "twitter_creator": { + "name": "twitter_creator", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "twitter_description": { + "name": "twitter_description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "metadata_image_idx": { + "name": "metadata_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_image_id_media_id_fk": { + "name": "metadata_image_id_media_id_fk", + "tableFrom": "metadata", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privacy_policy": { + "name": "privacy_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_goals": { + "name": "campaign_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "state_goal": { + "name": "state_goal", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 150000 + }, + "margin": { + "name": "margin", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "base_year": { + "name": "base_year", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_feed_settings_excluded_items": { + "name": "social_feed_settings_excluded_items", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum_social_feed_settings_excluded_items_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "social_feed_settings_excluded_items_order_idx": { + "name": "social_feed_settings_excluded_items_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "social_feed_settings_excluded_items_parent_id_idx": { + "name": "social_feed_settings_excluded_items_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_feed_settings_excluded_items_parent_id_fk": { + "name": "social_feed_settings_excluded_items_parent_id_fk", + "tableFrom": "social_feed_settings_excluded_items", + "tableTo": "social_feed_settings", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_feed_settings": { + "name": "social_feed_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "youtube_enabled": { + "name": "youtube_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "youtube_api_key": { + "name": "youtube_api_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "youtube_channel_id": { + "name": "youtube_channel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "youtube_max_items": { + "name": "youtube_max_items", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "instagram_enabled": { + "name": "instagram_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "instagram_access_token": { + "name": "instagram_access_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "instagram_user_id": { + "name": "instagram_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "instagram_max_items": { + "name": "instagram_max_items", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "youtube_feed_snapshot": { + "name": "youtube_feed_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "instagram_feed_snapshot": { + "name": "instagram_feed_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "instagram_sync_status": { + "name": "instagram_sync_status", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_album": { + "name": "photo_album", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "removal_channel_url": { + "name": "removal_channel_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "selfie_search_enabled": { + "name": "selfie_search_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.enum_users_roles": { + "name": "enum_users_roles", + "schema": "public", + "values": [ + "admin", + "editor" + ] + }, + "public.enum_campaign_user_role": { + "name": "enum_campaign_user_role", + "schema": "public", + "values": [ + "coordinator", + "advisor", + "candidate", + "communicator", + "leader" + ] + }, + "public.enum_campaign_user_visibility": { + "name": "enum_campaign_user_visibility", + "schema": "public", + "values": [ + "carteira", + "tudo" + ] + }, + "public.enum_campaign_user_editing": { + "name": "enum_campaign_user_editing", + "schema": "public", + "values": [ + "carteira", + "tudo", + "somente_leitura" + ] + }, + "public.enum_notification_type": { + "name": "enum_notification_type", + "schema": "public", + "values": [ + "municipality_update", + "new_supporter", + "activity_attention", + "invite_accepted" + ] + }, + "public.enum_campaign_invite_kind": { + "name": "enum_campaign_invite_kind", + "schema": "public", + "values": [ + "login", + "autopreenchimento" + ] + }, + "public.enum_municipality_kind": { + "name": "enum_municipality_kind", + "schema": "public", + "values": [ + "municipio", + "zona" + ] + }, + "public.enum_municipality_priority": { + "name": "enum_municipality_priority", + "schema": "public", + "values": [ + "alta", + "normal" + ] + }, + "public.enum_municipality_engagement_level": { + "name": "enum_municipality_engagement_level", + "schema": "public", + "values": [ + "n0", + "n1", + "n2", + "n3", + "n4" + ] + }, + "public.enum_municipality_political_trend_status": { + "name": "enum_municipality_political_trend_status", + "schema": "public", + "values": [ + "favoravel", + "neutra", + "desfavoravel" + ] + }, + "public.enum_leadership_support_status": { + "name": "enum_leadership_support_status", + "schema": "public", + "values": [ + "engajado", + "a_abordar", + "em_disputa", + "lembranca", + "negativo" + ] + }, + "public.enum_organization_kind": { + "name": "enum_organization_kind", + "schema": "public", + "values": [ + "sindicato", + "associacao", + "religioso", + "movimento", + "categoria_profissional", + "outro" + ] + }, + "public.enum_allocation_decision_outcome": { + "name": "enum_allocation_decision_outcome", + "schema": "public", + "values": [ + "aceita", + "descarta", + "movimento", + "adiada" + ] + }, + "public.enum_campaign_demand_status_history_status": { + "name": "enum_campaign_demand_status_history_status", + "schema": "public", + "values": [ + "aberta", + "em_analise", + "escalada", + "aprovada", + "rejeitada" + ] + }, + "public.enum_campaign_demand_kind": { + "name": "enum_campaign_demand_kind", + "schema": "public", + "values": [ + "material", + "servico", + "transporte", + "alimentacao", + "infraestrutura", + "espaco", + "equipamento", + "pessoal_apoio", + "outro" + ] + }, + "public.enum_campaign_demand_status": { + "name": "enum_campaign_demand_status", + "schema": "public", + "values": [ + "aberta", + "em_analise", + "escalada", + "aprovada", + "rejeitada" + ] + }, + "public.enum_supporter_vote_intention": { + "name": "enum_supporter_vote_intention", + "schema": "public", + "values": [ + "certo", + "tende_a_certo", + "indeciso", + "outro" + ] + }, + "public.enum_supporter_source": { + "name": "enum_supporter_source", + "schema": "public", + "values": [ + "import_csv", + "manual", + "lideranca", + "convite", + "evento" + ] + }, + "public.enum_municipality_update_polarity": { + "name": "enum_municipality_update_polarity", + "schema": "public", + "values": [ + "boa", + "neutra", + "ruim" + ] + }, + "public.enum_activity_status": { + "name": "enum_activity_status", + "schema": "public", + "values": [ + "confirmado", + "realizado", + "cancelado" + ] + }, + "public.enum_speech_topics": { + "name": "enum_speech_topics", + "schema": "public", + "values": [ + "saude", + "educacao", + "cultura", + "esporte", + "seguranca-publica", + "meio-ambiente", + "economia-trabalho", + "direitos-humanos", + "infraestrutura", + "ciencia-tecnologia", + "politica-instituicoes", + "agricultura", + "habitacao-cidades", + "comunicacao-midia", + "igualdade-racial", + "mulheres-genero", + "juventude", + "pessoa-deficiencia" + ] + }, + "public.enum_speech_scopes": { + "name": "enum_speech_scopes", + "schema": "public", + "values": [ + "bahia", + "brasil", + "internacional" + ] + }, + "public.enum_speech_origin": { + "name": "enum_speech_origin", + "schema": "public", + "values": [ + "camara", + "web" + ] + }, + "public.enum_speech_legislature": { + "name": "enum_speech_legislature", + "schema": "public", + "values": [ + "54", + "55", + "56", + "57" + ] + }, + "public.enum_speech_platform": { + "name": "enum_speech_platform", + "schema": "public", + "values": [ + "youtube", + "instagram", + "radio", + "audio" + ] + }, + "public.enum_speech_classified_by": { + "name": "enum_speech_classified_by", + "schema": "public", + "values": [ + "gazetteer", + "llm", + "manual" + ] + }, + "public.enum_speech_cut_status": { + "name": "enum_speech_cut_status", + "schema": "public", + "values": [ + "processing", + "published", + "unpublished", + "failed" + ] + }, + "public.enum_speech_cut_step": { + "name": "enum_speech_cut_step", + "schema": "public", + "values": [ + "resolving", + "cutting", + "metadata", + "publishing" + ] + }, + "public.enum_speech_embedding_kind": { + "name": "enum_speech_embedding_kind", + "schema": "public", + "values": [ + "speech", + "segment", + "window" + ] + }, + "public.enum_reel_feature": { + "name": "enum_reel_feature", + "schema": "public", + "values": [ + "cards" + ] + }, + "public.enum_reel_status": { + "name": "enum_reel_status", + "schema": "public", + "values": [ + "draft", + "published", + "unpublished" + ] + }, + "public.enum_content_piece_topics": { + "name": "enum_content_piece_topics", + "schema": "public", + "values": [ + "saude", + "educacao", + "cultura", + "esporte", + "seguranca-publica", + "meio-ambiente", + "economia-trabalho", + "direitos-humanos", + "infraestrutura", + "ciencia-tecnologia", + "politica-instituicoes", + "agricultura", + "habitacao-cidades", + "comunicacao-midia", + "igualdade-racial", + "mulheres-genero", + "juventude", + "pessoa-deficiencia" + ] + }, + "public.enum_content_piece_curated_fields": { + "name": "enum_content_piece_curated_fields", + "schema": "public", + "values": [ + "title", + "description", + "topics", + "municipality", + "institution", + "pieceDate", + "transcript", + "type" + ] + }, + "public.enum_content_piece_type": { + "name": "enum_content_piece_type", + "schema": "public", + "values": [ + "video", + "foto", + "texto", + "audio", + "card" + ] + }, + "public.enum_content_piece_origin": { + "name": "enum_content_piece_origin", + "schema": "public", + "values": [ + "arquivo", + "instagram", + "youtube" + ] + }, + "public.enum_content_piece_status": { + "name": "enum_content_piece_status", + "schema": "public", + "values": [ + "rascunho", + "publicado" + ] + }, + "public.enum_content_piece_processing_status": { + "name": "enum_content_piece_processing_status", + "schema": "public", + "values": [ + "processando", + "pronto", + "falhou" + ] + }, + "public.enum_content_piece_step": { + "name": "enum_content_piece_step", + "schema": "public", + "values": [ + "extraindo", + "transcrevendo", + "catalogando", + "salvando" + ] + }, + "public.enum_content_piece_link_failure_reason": { + "name": "enum_content_piece_link_failure_reason", + "schema": "public", + "values": [ + "nao-encontrado", + "carrossel", + "indisponivel", + "sem-credencial" + ] + }, + "public.enum_archive_photo_catalog_themes": { + "name": "enum_archive_photo_catalog_themes", + "schema": "public", + "values": [ + "saude", + "educacao", + "cultura", + "esporte", + "seguranca-publica", + "meio-ambiente", + "economia-trabalho", + "direitos-humanos", + "infraestrutura", + "ciencia-tecnologia", + "politica-instituicoes", + "agricultura", + "habitacao-cidades", + "comunicacao-midia", + "igualdade-racial", + "mulheres-genero", + "juventude", + "pessoa-deficiencia" + ] + }, + "public.enum_archive_photo_curated_fields": { + "name": "enum_archive_photo_curated_fields", + "schema": "public", + "values": [ + "alt", + "caption", + "description", + "scene", + "visibleText", + "hasPeople", + "themes", + "people", + "municipality" + ] + }, + "public.enum_archive_photo_catalog_scene": { + "name": "enum_archive_photo_catalog_scene", + "schema": "public", + "values": [ + "plenaria", + "audiencia", + "reuniao", + "evento", + "mobilizacao", + "visita", + "entrevista", + "discurso", + "retrato", + "gabinete", + "outro" + ] + }, + "public.enum_archive_photo_catalog_source": { + "name": "enum_archive_photo_catalog_source", + "schema": "public", + "values": [ + "ai", + "metadata", + "none" + ] + }, + "public.enum_archive_photo_publication_status": { + "name": "enum_archive_photo_publication_status", + "schema": "public", + "values": [ + "draft", + "approved", + "removed" + ] + }, + "public.enum_face_subject_status": { + "name": "enum_face_subject_status", + "schema": "public", + "values": [ + "active", + "removed" + ] + }, + "public.enum_content_event_type": { + "name": "enum_content_event_type", + "schema": "public", + "values": [ + "abertura", + "download", + "compartilhar_whatsapp", + "compartilhar_link" + ] + }, + "public.enum_content_event_subject_type": { + "name": "enum_content_event_subject_type", + "schema": "public", + "values": [ + "peca", + "card" + ] + }, + "public.enum_recording_topics": { + "name": "enum_recording_topics", + "schema": "public", + "values": [ + "saude", + "educacao", + "cultura", + "esporte", + "seguranca-publica", + "meio-ambiente", + "economia-trabalho", + "direitos-humanos", + "infraestrutura", + "ciencia-tecnologia", + "politica-instituicoes", + "agricultura", + "habitacao-cidades", + "comunicacao-midia", + "igualdade-racial", + "mulheres-genero", + "juventude", + "pessoa-deficiencia" + ] + }, + "public.enum_recording_scopes": { + "name": "enum_recording_scopes", + "schema": "public", + "values": [ + "bahia", + "brasil", + "internacional" + ] + }, + "public.enum_recording_status": { + "name": "enum_recording_status", + "schema": "public", + "values": [ + "uploading", + "processing", + "ready", + "failed" + ] + }, + "public.enum_recording_step": { + "name": "enum_recording_step", + "schema": "public", + "values": [ + "extracting", + "transcribing", + "saving" + ] + }, + "public.enum_recording_classified_by": { + "name": "enum_recording_classified_by", + "schema": "public", + "values": [ + "gazetteer", + "llm", + "manual" + ] + }, + "public.enum_election_tally_office": { + "name": "enum_election_tally_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_tally_turn": { + "name": "enum_election_tally_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_vote_office": { + "name": "enum_election_candidate_vote_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_candidate_vote_turn": { + "name": "enum_election_candidate_vote_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_vote_vote_type": { + "name": "enum_election_candidate_vote_vote_type", + "schema": "public", + "values": [ + "nominal", + "legenda" + ] + }, + "public.enum_election_candidate_office": { + "name": "enum_election_candidate_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_candidate_turn": { + "name": "enum_election_candidate_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_elected_by": { + "name": "enum_election_candidate_elected_by", + "schema": "public", + "values": [ + "QP", + "média", + "2º turno" + ] + }, + "public.enum_election_candidate_running_again2026": { + "name": "enum_election_candidate_running_again2026", + "schema": "public", + "values": [ + "sim", + "nao", + "desconhecido" + ] + }, + "public.enum_petition_status": { + "name": "enum_petition_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__petition_v_version_status": { + "name": "enum__petition_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_contact_gender": { + "name": "enum_contact_gender", + "schema": "public", + "values": [ + "feminino", + "masculino", + "outro", + "nao_informado" + ] + }, + "public.enum_contact_state": { + "name": "enum_contact_state", + "schema": "public", + "values": [ + "AC", + "AL", + "AM", + "AP", + "BA", + "CE", + "DF", + "ES", + "GO", + "MA", + "MG", + "MS", + "MT", + "PA", + "PB", + "PE", + "PI", + "PR", + "RJ", + "RN", + "RO", + "RR", + "RS", + "SC", + "SE", + "SP", + "TO" + ] + }, + "public.enum_subscription_campaign_level": { + "name": "enum_subscription_campaign_level", + "schema": "public", + "values": [ + "time", + "esporadico" + ] + }, + "public.enum_post_type": { + "name": "enum_post_type", + "schema": "public", + "values": [ + "noticia", + "campanha", + "artigo", + "evento" + ] + }, + "public.enum_post_status": { + "name": "enum_post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__post_v_version_type": { + "name": "enum__post_v_version_type", + "schema": "public", + "values": [ + "noticia", + "campanha", + "artigo", + "evento" + ] + }, + "public.enum__post_v_version_status": { + "name": "enum__post_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_share_link_mode": { + "name": "enum_share_link_mode", + "schema": "public", + "values": [ + "direct", + "announcement" + ] + }, + "public.enum_exports_format": { + "name": "enum_exports_format", + "schema": "public", + "values": [ + "csv", + "json" + ] + }, + "public.enum_exports_sort_order": { + "name": "enum_exports_sort_order", + "schema": "public", + "values": [ + "asc", + "desc" + ] + }, + "public.enum_exports_drafts": { + "name": "enum_exports_drafts", + "schema": "public", + "values": [ + "yes", + "no" + ] + }, + "public.enum_imports_import_mode": { + "name": "enum_imports_import_mode", + "schema": "public", + "values": [ + "create", + "update", + "upsert" + ] + }, + "public.enum_imports_status": { + "name": "enum_imports_status", + "schema": "public", + "values": [ + "pending", + "completed", + "partial", + "failed" + ] + }, + "public.enum_payload_jobs_log_task_slug": { + "name": "enum_payload_jobs_log_task_slug", + "schema": "public", + "values": [ + "inline", + "createCollectionExport", + "createCollectionImport", + "schedulePublish" + ] + }, + "public.enum_payload_jobs_log_state": { + "name": "enum_payload_jobs_log_state", + "schema": "public", + "values": [ + "failed", + "succeeded" + ] + }, + "public.enum_payload_jobs_task_slug": { + "name": "enum_payload_jobs_task_slug", + "schema": "public", + "values": [ + "inline", + "createCollectionExport", + "createCollectionImport", + "schedulePublish" + ] + }, + "public.enum_site_settings_social_links_platform": { + "name": "enum_site_settings_social_links_platform", + "schema": "public", + "values": [ + "instagram", + "facebook", + "youtube", + "whatsapp" + ] + }, + "public.enum_site_settings_status": { + "name": "enum_site_settings_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__site_settings_v_version_social_links_platform": { + "name": "enum__site_settings_v_version_social_links_platform", + "schema": "public", + "values": [ + "instagram", + "facebook", + "youtube", + "whatsapp" + ] + }, + "public.enum__site_settings_v_version_status": { + "name": "enum__site_settings_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_social_feed_settings_excluded_items_platform": { + "name": "enum_social_feed_settings_excluded_items_platform", + "schema": "public", + "values": [ + "youtube", + "instagram" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "id": "4a979f01-8a27-45e0-9488-2d9baa3f5292", + "prevId": "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/src/migrations/20260927_193954_add_face_subject.ts b/src/migrations/20260927_193954_add_face_subject.ts new file mode 100644 index 00000000..88489bfb --- /dev/null +++ b/src/migrations/20260927_193954_add_face_subject.ts @@ -0,0 +1,61 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + 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" ADD COLUMN "faces_checked_at" timestamp(3) with time zone; + ALTER TABLE "archive_photo" ADD COLUMN "faces_checked_key" varchar; + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_subject_id" integer; + ALTER TABLE "photo_album" ADD COLUMN "selfie_search_enabled" boolean DEFAULT false; + 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");`) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "face_subject" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "face_subject_rels" DISABLE ROW LEVEL SECURITY; + DROP TABLE "face_subject" CASCADE; + DROP TABLE "face_subject_rels" CASCADE; + 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"; + ALTER TABLE "archive_photo" DROP COLUMN "faces_checked_at"; + ALTER TABLE "archive_photo" DROP COLUMN "faces_checked_key"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_subject_id"; + ALTER TABLE "photo_album" DROP COLUMN "selfie_search_enabled"; + DROP TYPE "public"."enum_face_subject_status";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 65d8a1d3..b8348b57 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -90,6 +90,7 @@ import * as migration_20260926_034118_add_speech_embedding from './20260926_0341 import * as migration_20260926_062039_add_archive_photo from './20260926_062039_add_archive_photo'; 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'; export const migrations = [ { @@ -550,6 +551,11 @@ export const migrations = [ { up: migration_20260926_163248_add_archive_photo_publication.up, down: migration_20260926_163248_add_archive_photo_publication.down, - name: '20260926_163248_add_archive_photo_publication' + name: '20260926_163248_add_archive_photo_publication', + }, + { + up: migration_20260927_193954_add_face_subject.up, + down: migration_20260927_193954_add_face_subject.down, + name: '20260927_193954_add_face_subject' }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 940dd700..4557d11a 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -96,6 +96,7 @@ export interface Config { contentPiece: ContentPiece; contentMedia: ContentMedia; archivePhoto: ArchivePhoto; + faceSubject: FaceSubject; contentEvent: ContentEvent; recording: Recording; recordingMedia: RecordingMedia; @@ -153,6 +154,7 @@ export interface Config { contentPiece: ContentPieceSelect | ContentPieceSelect; contentMedia: ContentMediaSelect | ContentMediaSelect; archivePhoto: ArchivePhotoSelect | ArchivePhotoSelect; + faceSubject: FaceSubjectSelect | FaceSubjectSelect; contentEvent: ContentEventSelect | ContentEventSelect; recording: RecordingSelect | RecordingSelect; recordingMedia: RecordingMediaSelect | RecordingMediaSelect; @@ -1563,6 +1565,13 @@ export interface ArchivePhoto { * Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags. */ searchText?: string | null; + /** + * Estado do lote da busca por selfie (C234). Nunca editado à mão. + */ + faces?: { + checkedAt?: string | null; + checkedKey?: string | null; + }; updatedAt: string; createdAt: string; url?: string | null; @@ -1575,6 +1584,58 @@ export interface ArchivePhoto { focalX?: number | null; 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. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "faceSubject". + */ +export interface FaceSubject { + id: number; + /** + * Como a assessoria identifica a pessoa; nunca aparece no site. + */ + label: string; + /** + * O Consent da adesão ao índice (chave estável busca-selfie-indice). + */ + 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; + vector?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | 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; +} /** * Eventos anônimos de circulação (abertura, download, compartilhamento). * @@ -2409,6 +2470,10 @@ export interface PayloadLockedDocument { relationTo: 'archivePhoto'; value: number | ArchivePhoto; } | null) + | ({ + relationTo: 'faceSubject'; + value: number | FaceSubject; + } | null) | ({ relationTo: 'contentEvent'; value: number | ContentEvent; @@ -3201,6 +3266,12 @@ export interface ArchivePhotoSelect { municipalityName?: T; municipalitySlug?: T; searchText?: T; + faces?: + | T + | { + checkedAt?: T; + checkedKey?: T; + }; updatedAt?: T; createdAt?: T; url?: T; @@ -3213,6 +3284,23 @@ export interface ArchivePhotoSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "faceSubject_select". + */ +export interface FaceSubjectSelect { + label?: T; + consent?: T; + consentHash?: T; + model?: T; + enrolledAt?: T; + vector?: T; + status?: T; + removedAt?: T; + matchedPhotos?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "contentEvent_select". @@ -3917,6 +4005,10 @@ export interface PhotoAlbum { * Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto. */ removalChannelUrl?: string | null; + /** + * Liga a busca por selfie (/fotos/encontre). Nasce desligada: só abra com o consentimento configurado e o aval jurídico/DPIA registrados (C234). + */ + selfieSearchEnabled?: boolean | null; updatedAt?: string | null; createdAt?: string | null; } @@ -4046,6 +4138,7 @@ export interface SocialFeedSettingsSelect { export interface PhotoAlbumSelect { published?: T; removalChannelUrl?: T; + selfieSearchEnabled?: T; updatedAt?: T; createdAt?: T; globalType?: T; @@ -4098,6 +4191,7 @@ export interface TaskCreateCollectionExport { | 'contentPiece' | 'contentMedia' | 'archivePhoto' + | 'faceSubject' | 'contentEvent' | 'recording' | 'recordingMedia' diff --git a/src/payload.config.ts b/src/payload.config.ts index 6b8ca6fc..d09d7db9 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -26,6 +26,7 @@ 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' @@ -139,6 +140,7 @@ export default buildConfig({ ContentPiece, ContentMedia, ArchivePhoto, + FaceSubject, ContentEvent, Recording, RecordingMedia, diff --git a/src/utilities/boundedRequestBody.ts b/src/utilities/boundedRequestBody.ts new file mode 100644 index 00000000..ace78122 --- /dev/null +++ b/src/utilities/boundedRequestBody.ts @@ -0,0 +1,41 @@ +import 'server-only' + +/** + * Reads a request body with a hard byte ceiling, streaming and aborting as + * soon as the ceiling is crossed — a chunked body without `content-length` + * cannot make a route buffer unbounded input. `null` means "refused". + * + * Single owner of the bounded-read rule for the anonymous public JSON routes + * (the content-events beacon and the selfie search); extracted from the beacon + * route when it earned a second caller. + */ +export const readBoundedRequestBody = async ( + request: Request, + maxBytes: number, +): Promise => { + const body = request.body + if (!body) return '' + + const reader = body.getReader() + const decoder = new TextDecoder() + let text = '' + let bytes = 0 + + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + + bytes += value.byteLength + if (bytes > maxBytes) { + await reader.cancel().catch(() => undefined) + return null + } + text += decoder.decode(value, { stream: true }) + } + return text + decoder.decode() + } catch { + await reader.cancel().catch(() => undefined) + return null + } +} diff --git a/src/utilities/content/contentEventRateLimit.ts b/src/utilities/content/contentEventRateLimit.ts index 0c985571..8361c951 100644 --- a/src/utilities/content/contentEventRateLimit.ts +++ b/src/utilities/content/contentEventRateLimit.ts @@ -48,9 +48,15 @@ const cleanup = (now: number): void => { /** * `true` when the event may be recorded. A null key (no trustworthy IP) and - * any internal failure answer `true` — the fail-open contract. + * any internal failure answer `true` — the fail-open contract. `budget` + * defaults to the beacon's generous window; a stricter caller (the C234 selfie + * search, which answers a membership oracle) passes its own ceiling while + * sharing the same registry, hash and cleanup. */ -export const checkContentEventRateLimit = (key: string | null): boolean => { +export const checkContentEventRateLimit = ( + key: string | null, + budget: number = MAX_EVENTS_PER_WINDOW, +): boolean => { if (!key) return true try { @@ -63,7 +69,7 @@ export const checkContentEventRateLimit = (key: string | null): boolean => { store.set(key, { count: 1, resetAt: now + WINDOW_MS }) return true } - if (existing.count >= MAX_EVENTS_PER_WINDOW) return false + if (existing.count >= budget) return false existing.count += 1 return true diff --git a/src/utilities/faceSubjects/faceSubjectEnrollment.ts b/src/utilities/faceSubjects/faceSubjectEnrollment.ts new file mode 100644 index 00000000..ea946c10 --- /dev/null +++ b/src/utilities/faceSubjects/faceSubjectEnrollment.ts @@ -0,0 +1,102 @@ +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 => { + 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 => { + 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, + }) +} diff --git a/src/utilities/faceSubjects/faceSubjectPhotoIndex.ts b/src/utilities/faceSubjects/faceSubjectPhotoIndex.ts new file mode 100644 index 00000000..638023d0 --- /dev/null +++ b/src/utilities/faceSubjects/faceSubjectPhotoIndex.ts @@ -0,0 +1,290 @@ +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 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 { messageOf } from '@/utilities/media/ffmpeg' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { + downloadPrivateMediaToFile, + resolvePrivateMediaStaticDir, +} 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. + * + * 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. + * + * A failure of one photo is returned, never thrown: nothing is written, the + * next run retries it and the receipt names the stage. + */ + +/** Long edge sent to the engine; the original never leaves the archive. */ +const IMAGE_MAX_EDGE = 1024 + +export type ArchivePhotoFaceImage = { + pixels: Buffer + width: number + height: number +} + +/** The engine seam: `src/` never imports `scripts/`; the CLI injects it. */ +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 selfie and the archive enter the same space. + */ +export const prepareFaceImage = async (filePath: string): Promise => { + const prepared = await sharp(filePath) + .rotate() + .resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + + return { pixels: prepared.data, width: prepared.info.width, height: prepared.info.height } +} + +export type ArchivePhotoFaceIndexItem = { + id: number + filename: string | null + checkedKey: string | null +} + +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 + /** The work list (newest first); capped by `limit` when asked. */ + items: ArchivePhotoFaceIndexItem[] +} + +export type ArchivePhotoFaceIndexResult = + | { status: 'indexed'; matchedSubjects: 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). + */ +export const listArchivePhotoFaceIndexQueue = async ({ + payload, + refresh = false, + limit, +}: { + payload: Payload + refresh?: boolean + limit?: number +}): Promise => { + const subjects = await listFaceSubjects(payload) + const indexKey = facePhotoIndexKey(subjects) + + const photos = await payload.find({ + collection: ARCHIVE_PHOTO_SLUG, + where: { publicationStatus: { equals: 'approved' } }, + sort: ['-takenOn', '-id'], + depth: 0, + limit: 0, + pagination: false, + select: { filename: true, faces: true }, + // Intentional bypass: the batch CLI is a trusted operator with no session. + overrideAccess: true, + }) + + const rows: ArchivePhotoFaceIndexItem[] = photos.docs.map((doc) => ({ + id: doc.id, + filename: doc.filename ?? null, + checkedKey: doc.faces?.checkedKey ?? null, + })) + const staleRows = refresh ? rows : rows.filter((row) => row.checkedKey !== indexKey) + + return { + indexKey, + eligibleSubjects: subjects.filter(isIndexableSubject).length, + totalApproved: photos.docs.length, + stale: staleRows.length, + items: limit && limit > 0 ? staleRows.slice(0, limit) : staleRows, + } +} + +/** + * 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). + */ +export const indexArchivePhotoFaces = async ({ + payload, + item, + indexKey, + analyze, +}: { + payload: Payload + item: ArchivePhotoFaceIndexItem + indexKey: string + analyze: ArchivePhotoFaceAnalyzer +}): Promise => { + if (!item.filename) { + return { status: 'failed', stage: 'download', error: 'Foto sem arquivo armazenado.' } + } + + const tempDir = await mkdtemp(join(tmpdir(), 'teqo-face-index-')) + const imagePath = join(tempDir, 'original') + + try { + try { + await downloadPrivateMediaToFile({ + media: { filename: item.filename }, + staticDir: resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG), + destinationPath: imagePath, + }) + } catch (error) { + return { status: 'failed', stage: 'download', error: messageOf(error, 'erro desconhecido') } + } + + let image: ArchivePhotoFaceImage + try { + image = await prepareFaceImage(imagePath) + } catch (error) { + return { status: 'failed', stage: 'image', error: messageOf(error, 'erro desconhecido') } + } + + let descriptors: number[][] + try { + descriptors = await analyze(image) + } catch (error) { + return { status: 'failed', stage: 'analyze', error: messageOf(error, 'erro desconhecido') } + } + + try { + const matchedSubjects = await writeArchivePhotoFaceMatches({ + payload, + photoId: item.id, + descriptors, + indexKey, + }) + return { status: 'indexed', matchedSubjects } + } catch (error) { + return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') } + } + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + } +} + +/** + * 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. + */ +const writeArchivePhotoFaceMatches = async ({ + payload, + photoId, + descriptors, + indexKey, +}: { + payload: Payload + photoId: number + descriptors: number[][] + indexKey: string +}): Promise => + withPayloadTransaction(payload, async ({ req }) => { + const subjects = await listFaceSubjects(payload, req) + const matchedSubjects: number[] = [] + + for (const subject of subjects) { + if (!isIndexableSubject(subject)) continue + + 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 }, + depth: 0, + req, + // Intentional bypass: the batch CLI is a trusted operator with no session. + overrideAccess: true, + }) + } + + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: photoId, + data: { faces: { checkedAt: new Date().toISOString(), checkedKey: indexKey } }, + depth: 0, + req, + context: { faceIndex: true }, + // Intentional bypass: the batch CLI is a trusted operator with no session. + overrideAccess: true, + }) + + return matchedSubjects + }) diff --git a/src/utilities/faceSubjects/faceSubjectReads.ts b/src/utilities/faceSubjects/faceSubjectReads.ts new file mode 100644 index 00000000..c4ef4a97 --- /dev/null +++ b/src/utilities/faceSubjects/faceSubjectReads.ts @@ -0,0 +1,136 @@ +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 => { + 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 => { + 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 => { + 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) +} diff --git a/tests/e2e/frontendFotosSelfie.e2e.spec.ts b/tests/e2e/frontendFotosSelfie.e2e.spec.ts new file mode 100644 index 00000000..5d113854 --- /dev/null +++ b/tests/e2e/frontendFotosSelfie.e2e.spec.ts @@ -0,0 +1,444 @@ +import { randomUUID } from 'node:crypto' + +import type { APIRequestContext, Page } from '@playwright/test' +import sharp from 'sharp' + +import { + FACE_INDEX_CONSENT_KEY, + 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. + * + * 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`). + */ + +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' +const REMOVAL_CHANNEL = 'https://example.org/acervo/remocao' + +type Headers = Record + +const createdPhotoIds: number[] = [] +const createdSubjectIds: number[] = [] +const createdConsentKeys = new Set() + +const uniqueMarker = () => randomUUID().slice(0, 8) + +const fixtureConsentText = (label: string) => ({ + root: { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', text: `Consentimento de teste C234 (${label}).`, version: 1 }], + direction: null, + format: '', + indent: 0, + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + version: 1, + }, +}) + +const jpegWithFill = (marker: string): Promise => { + const byte = Number.parseInt(marker.slice(0, 2), 16) % 200 + return sharp({ + create: { + width: 96, + height: 96, + channels: 3, + background: { r: 30 + byte, g: 120, b: 40 + (byte % 60) }, + }, + }) + .jpeg() + .toBuffer() +} + +const setAlbum = async ( + request: APIRequestContext, + headers: Headers, + data: { published: boolean; removalChannelUrl: string | null; selfieSearchEnabled: boolean }, +): Promise => { + const response = await request.post(`${BASE_URL}/api/globals/photoAlbum`, { headers, data }) + expect(response.ok(), await response.text()).toBeTruthy() +} + +const createPhoto = async ( + request: APIRequestContext, + headers: Headers, + caption: string, +): Promise => { + const marker = uniqueMarker() + const response = await request.post(`${BASE_URL}/api/archivePhoto`, { + headers, + multipart: { + _payload: JSON.stringify({ + flickrId: `c234-e2e-${marker}`, + alt: `Foto de teste C234 ${marker}: ${caption}`, + publicationStatus: 'approved', + takenAt: '2026-09-12 10:20:30', + catalog: { caption, people: ['Jorge Solla'] }, + }), + file: { + name: `c234-${marker}.jpg`, + mimeType: 'image/jpeg', + buffer: await jpegWithFill(marker), + }, + }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + const id = ((await response.json()) as { doc: { id: number } }).doc.id + createdPhotoIds.push(id) + return id +} + +const ensureConsent = async ( + request: APIRequestContext, + headers: Headers, + key: string, +): Promise<{ id: number; text: unknown }> => { + const existing = await request.get( + `${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`, + { headers }, + ) + expect(existing.ok(), await existing.text()).toBeTruthy() + const found = ((await existing.json()) as { docs: { id: number; text: unknown }[] }).docs[0] + if (found) return found + + const created = await request.post(`${BASE_URL}/api/consent`, { + headers, + data: { key, text: fixtureConsentText(key) }, + }) + expect(created.ok(), await created.text()).toBeTruthy() + createdConsentKeys.add(key) + const doc = ((await created.json()) as { doc: { id: number; text: unknown } }).doc + return { id: doc.id, text: doc.text } +} + +const createSubject = async ( + request: APIRequestContext, + headers: Headers, + data: { vector: number[]; consentId: number; consentHash: string; matchedPhotos: number[] }, +): Promise => { + const response = await request.post(`${BASE_URL}/api/faceSubject`, { + headers, + data: { + label: `Pessoa de teste ${uniqueMarker()}`, + consent: data.consentId, + consentHash: data.consentHash, + model: FACE_SEARCH_MODEL, + 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 +} + +const faceSubjectById = 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 }) + expect(response.ok(), await response.text()).toBeTruthy() + return (await response.json()) as { status: string; vector: unknown } +} + +/** Consent card → pick screen with the given fixture, ready for the primary action. */ +const reachPickWithFixture = async ( + page: Page, + fixture: { name: string; mimeType: string; buffer: Buffer }, + { firstPick = false }: { firstPick?: boolean } = {}, +) => { + if (firstPick) { + await page.getByRole('checkbox').check() + await page.getByRole('button', { name: 'Concordar e escolher minha selfie' }).click() + } + await page.locator('input[type="file"]').setInputFiles(fixture) +} + +test.describe.configure({ mode: 'serial' }) + +test.describe('Frontend busca por selfie (C234)', () => { + let headers: Headers + let indexConsent: { id: number; text: unknown } + let indexConsentHash: string + let subjectId: number + let linkedPhoto: number + let draftLinkedPhoto: number + let otherPhoto: number + const marker = uniqueMarker() + + type SelfieFixture = { name: string; mimeType: string; buffer: Buffer } + const selfieA: SelfieFixture = { + name: 'selfie-a.jpg', + mimeType: 'image/jpeg', + buffer: Buffer.alloc(0), + } + const selfieB: SelfieFixture = { + name: 'selfie-b.jpg', + mimeType: 'image/jpeg', + buffer: Buffer.alloc(0), + } + + test.beforeAll(async ({ request }) => { + await seedTestUser() + headers = await adminHeaders(request, BASE_URL) + + selfieA.buffer = await jpegWithFill('11') + 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 setAlbum(request, headers, { + published: true, + removalChannelUrl: REMOVAL_CHANNEL, + selfieSearchEnabled: true, + }) + + linkedPhoto = await createPhoto(request, headers, `Plenária encontrada ${marker}`) + otherPhoto = await createPhoto(request, headers, `Outra foto aprovada ${marker}`) + draftLinkedPhoto = await createPhoto(request, headers, `Rascunho do índice ${marker}`) + await request.patch(`${BASE_URL}/api/archivePhoto/${draftLinkedPhoto}`, { + headers, + data: { publicationStatus: 'draft' }, + }) + + subjectId = await createSubject(request, headers, { + vector: faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer)), + consentId: indexConsent.id, + consentHash: indexConsentHash, + matchedPhotos: [linkedPhoto, draftLinkedPhoto], + }) + }) + + 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 }) + .catch(() => undefined) + } + for (const key of createdConsentKeys) { + const lookup = await request + .get( + `${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`, + { + headers: cleanupHeaders, + }, + ) + .catch(() => null) + const doc = lookup ? ((await lookup.json()) as { docs: { id: number }[] }).docs[0] : null + if (doc) { + await request + .delete(`${BASE_URL}/api/consent/${doc.id}`, { headers: cleanupHeaders }) + .catch(() => undefined) + } + } + await setAlbum(request, cleanupHeaders, { + published: true, + removalChannelUrl: REMOVAL_CHANNEL, + selfieSearchEnabled: false, + }).catch(() => undefined) + } + }) + + test('gates the entry on the album flag and the flow on the explicit consent', async ({ + page, + }) => { + await page.goto('/fotos') + await waitForStreamSettled(page) + const entry = page.getByRole('link', { name: 'Começar busca por selfie' }) + await expect(entry).toBeVisible() + await entry.click() + await page.waitForURL('**/fotos/encontre') + await waitForStreamSettled(page) + + await expect( + page.getByRole('heading', { name: 'Sua face é um dado biométrico sensível' }), + ).toBeVisible() + const primary = page.getByRole('button', { name: 'Concordar e escolher minha selfie' }) + await expect(primary).toBeDisabled() + await page.getByRole('checkbox').check() + await expect(primary).toBeEnabled() + }) + + test('finds only the approved photos of the matched subject, without third-party names', async ({ + page, + }) => { + // `slow` keeps the processing state on screen long enough to be asserted. + await page.addInitScript(() => { + ;(window as unknown as { __faceSearchStub?: string }).__faceSearchStub = 'slow' + }) + await page.goto('/fotos/encontre') + await waitForStreamSettled(page) + await reachPickWithFixture(page, selfieA, { firstPick: true }) + await page.getByRole('button', { name: 'Usar esta selfie' }).click() + + await expect( + page.getByRole('heading', { name: 'Procurando você nas fotos aprovadas…' }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Estas são as fotos em que encontramos você.' }), + ).toBeVisible() + + const card = page.locator(`a[data-photo-id="${linkedPhoto}"]`) + await expect(card).toBeVisible() + await expect(card.getByText(`Plenária encontrada ${marker}`)).toBeVisible() + // The draft is linked to the subject but is not public; the other approved + // photo does not contain the enrolled face. + await expect(page.locator(`a[data-photo-id="${draftLinkedPhoto}"]`)).toHaveCount(0) + await expect(page.locator(`a[data-photo-id="${otherPhoto}"]`)).toHaveCount(0) + // The result card never carries the album's "Quem aparece" line. + await expect(card.getByText('Jorge Solla')).toHaveCount(0) + await expect(page.getByText(/nota de semelhança/)).toBeVisible() + }) + + test('shows the honest empty state for a face that is not in the index', async ({ page }) => { + await page.goto('/fotos/encontre') + await waitForStreamSettled(page) + await reachPickWithFixture(page, selfieB, { firstPick: true }) + await page.getByRole('button', { name: 'Usar esta selfie' }).click() + + await expect(page.getByRole('heading', { name: 'Não encontramos fotos suas' })).toBeVisible() + await expect(page.getByText(/pessoas parecidas/)).toBeVisible() + }) + + test('shows the no-match opt-out state with the configured removal channel', async ({ page }) => { + await page.goto('/fotos/encontre') + await waitForStreamSettled(page) + // The presence screen is reached from the result band (the person is in the + // index); the leave attempt then uses a selfie that does not match. + await reachPickWithFixture(page, selfieA, { firstPick: true }) + await page.getByRole('button', { name: 'Usar esta selfie' }).click() + await page.getByRole('button', { name: 'Gerenciar minha presença' }).click() + await page.getByRole('button', { name: 'Continuar' }).click() + await page.locator('input[type="file"]').setInputFiles(selfieB) + await page.getByRole('button', { name: 'Continuar' }).click() + await page.getByRole('button', { name: 'Confirmar saída do índice' }).click() + + await expect( + page.getByRole('heading', { name: 'Não encontramos sua presença no índice' }), + ).toBeVisible() + const channel = page.getByRole('link', { name: 'Pedir remoção de uma foto' }) + await expect(channel).toBeVisible() + await expect(channel).toHaveAttribute('href', REMOVAL_CHANNEL) + }) + + test('serves the opt-out by matched descriptor and stops answering afterwards', async ({ + page, + request, + }) => { + await page.goto('/fotos/encontre') + await waitForStreamSettled(page) + await reachPickWithFixture(page, selfieA, { firstPick: true }) + await page.getByRole('button', { name: 'Usar esta selfie' }).click() + await page.getByRole('button', { name: 'Gerenciar minha presença' }).click() + + await page.getByRole('button', { name: 'Continuar' }).click() + await page.locator('input[type="file"]').setInputFiles(selfieA) + await page.getByRole('button', { name: 'Continuar' }).click() + + 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() + + const subject = await faceSubjectById(request, headers, subjectId) + expect(subject.status).toBe('removed') + expect(subject.vector).toBeNull() + }) + + test('fails the engine honestly when the local processing breaks', async ({ page }) => { + await page.addInitScript(() => { + ;(window as unknown as { __faceSearchStub?: string }).__faceSearchStub = 'error' + }) + await page.goto('/fotos/encontre') + await waitForStreamSettled(page) + await reachPickWithFixture(page, selfieA, { firstPick: true }) + await page.getByRole('button', { name: 'Usar esta selfie' }).click() + + await expect( + page.getByRole('heading', { name: 'Não foi possível processar a busca' }), + ).toBeVisible() + await expect(page.getByText(/não foi enviada nem guardada/)).toBeVisible() + }) + + test('closes the page when the query Consent disappears', async ({ page, request }) => { + const lookup = await request.get( + `${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${FACE_SEARCH_CONSENT_KEY}`, + { headers }, + ) + const doc = ((await lookup.json()) as { docs: { id: number }[] }).docs[0] + expect(doc).toBeDefined() + await request.delete(`${BASE_URL}/api/consent/${doc!.id}`, { headers }) + + try { + await page.goto('/fotos/encontre') + await waitForStreamSettled(page) + await expect( + page.getByRole('heading', { name: 'A busca por selfie está indisponível' }), + ).toBeVisible() + } finally { + await ensureConsent(request, headers, FACE_SEARCH_CONSENT_KEY) + } + }) + + test('removes the entry from the album when the feature flag is off', async ({ + page, + request, + }) => { + await setAlbum(request, headers, { + published: true, + removalChannelUrl: REMOVAL_CHANNEL, + selfieSearchEnabled: false, + }) + try { + await page.goto('/fotos') + await waitForStreamSettled(page) + await expect(page.getByRole('link', { name: 'Começar busca por selfie' })).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). + expect((await request.get(`${BASE_URL}/fotos/encontre`)).status()).toBe(404) + } finally { + await setAlbum(request, headers, { + published: true, + removalChannelUrl: REMOVAL_CHANNEL, + selfieSearchEnabled: true, + }) + } + }) +}) diff --git a/tests/helpers/faceIntFixtures.ts b/tests/helpers/faceIntFixtures.ts new file mode 100644 index 00000000..b1e80299 --- /dev/null +++ b/tests/helpers/faceIntFixtures.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +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 { 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. + */ +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 faceTestVector = (value: number): number[] => + Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => value) + +const record = ( + flickrId: string, + overrides: Partial = {}, +): ArchivePhotoImport => ({ + flickrId, + sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`, + owner: '12345678@N00', + license: '0', + title: `Foto ${flickrId}`, + description: null, + tags: [], + takenAt: '2025-03-14 10:20:30', + postedAt: '2025-03-15T10:00:00.000Z', + albums: [], + geo: null, + exif: [], + originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`, + originalKind: 'original', + ...overrides, +}) + +/** + * Ingests one archive photo fixture and optionally approves it (approval needs + * the album's removal channel to be configured by the caller). + */ +export const createFaceTestPhoto = async ({ + payload, + approved, + prefix, + trackedFlickrIds, + tempDirs, +}: { + payload: Payload + approved: boolean + prefix: string + trackedFlickrIds: Set + tempDirs: string[] +}): Promise<{ id: number; flickrId: string }> => { + const flickrId = `${prefix}-${randomUUID().slice(0, 8)}` + trackedFlickrIds.add(flickrId) + const dir = await mkdtemp(join(tmpdir(), `${prefix}-photo-`)) + tempDirs.push(dir) + const filePath = join(dir, `flickr-${flickrId}.jpg`) + await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES) + const created = await ingestArchivePhoto(payload, record(flickrId), { filePath }) + if (created.status !== 'created') throw new Error(`fixture not created: ${created.status}`) + + if (approved) { + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: created.id, + data: { publicationStatus: 'approved' }, + overrideAccess: true, + }) + } + return { id: created.id, flickrId } +} + +/** Enrolls a subject with the current index Consent resolved by stable key. */ +export const enrollFaceTestSubject = async ({ + payload, + label, + vector, + trackedSubjectIds, +}: { + payload: Payload + label: string + vector: number[] + trackedSubjectIds: Set +}): Promise => { + const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY) + if (!consent) throw new Error('index consent not configured') + + const enrolled = await enrollFaceSubject({ + payload, + label, + descriptor: vector, + consent: { id: consent.id, contentHash: consent.contentHash }, + }) + trackedSubjectIds.add(enrolled.id) + return enrolled.id +} diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts index 4ef1b962..00a69237 100644 --- a/tests/helpers/testDatabaseLease.ts +++ b/tests/helpers/testDatabaseLease.ts @@ -126,6 +126,17 @@ export const purgeOrphanedConsentRenames = async ( export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent' export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent' export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent' + +/** + * Serializes spec files that write the `photoAlbum` global. A Payload global is + * a single row created by the first write; two parallel spec files writing a + * missing global race the creation and the table ends with TWO rows — every + * later read then picks one of them by `created_at` and the specs disagree + * about the state (observed when the C233 album spec and the C234 face specs + * ran in parallel). + */ +export const PHOTO_ALBUM_LEASE_KEY = 'photo-album-global' + /** * Serializes spec files that treat the election collections as test-owned: * `electionResultsImport.int.spec.ts` wipes ALL election rows, which would diff --git a/tests/int/archivePhotoPublicRead.int.spec.ts b/tests/int/archivePhotoPublicRead.int.spec.ts index 2bd15318..36afe422 100644 --- a/tests/int/archivePhotoPublicRead.int.spec.ts +++ b/tests/int/archivePhotoPublicRead.int.spec.ts @@ -26,6 +26,7 @@ import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest' import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture' import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease' // C233 — the public album boundary over the real Payload `teqo_test`: the // approve/draft/removed gate, the sticky removal, the removal-channel guard, @@ -78,21 +79,28 @@ const createPhoto = async (): Promise<{ id: number; flickrId: string }> => { return { id: created.id, flickrId } } +// The `photoAlbum` global is a single row created by the first write: every +// spec file that writes it serializes on the shared lease so parallel forks +// cannot race the creation into two rows (C234 specs write it too). const setChannel = async (removalChannelUrl: string): Promise => { - await payload.updateGlobal({ - slug: 'photoAlbum', - data: { published: true, removalChannelUrl }, - overrideAccess: true, - }) + await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () => + payload.updateGlobal({ + slug: 'photoAlbum', + data: { published: true, removalChannelUrl }, + overrideAccess: true, + }), + ) } /** Published is false here on purpose: the guard refuses an open album with no channel. */ const closeAlbumAndClearChannel = async (): Promise => { - await payload.updateGlobal({ - slug: 'photoAlbum', - data: { published: false, removalChannelUrl: null }, - overrideAccess: true, - }) + await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () => + payload.updateGlobal({ + slug: 'photoAlbum', + data: { published: false, removalChannelUrl: null }, + overrideAccess: true, + }), + ) } const approve = async (id: number): Promise => { diff --git a/tests/int/faceSearchApi.int.spec.ts b/tests/int/faceSearchApi.int.spec.ts new file mode 100644 index 00000000..7e7dfed0 --- /dev/null +++ b/tests/int/faceSearchApi.int.spec.ts @@ -0,0 +1,286 @@ +// @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 { 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 config from '@/payload.config' +import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment' +import { + indexArchivePhotoFaces, + listArchivePhotoFaceIndexQueue, +} from '@/utilities/faceSubjects/faceSubjectPhotoIndex' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { + createFaceTestPhoto, + enrollFaceTestSubject, + FACE_INDEX_LEASE_KEY, + FACE_SEARCH_LEASE_KEY, + FACE_SPECS_LEASE_KEY, + faceTestVector, +} from '../helpers/faceIntFixtures' +import { + acquireTestDatabaseLease, + ensureLeasedConsent, + PHOTO_ALBUM_LEASE_KEY, + 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. + +let payload: Payload +const createdFlickrIds = new Set() +const createdSubjectIds = new Set() +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: 'c234-api', + trackedFlickrIds: createdFlickrIds, + tempDirs, + }) + +const enrollFixtureSubject = (label: string, vector: number[]) => + enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds }) + +const indexPhotoForSubject = async (photoId: number, vector: number[]): Promise => { + 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 => { + await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () => + payload.updateGlobal({ + slug: 'photoAlbum', + data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: enabled }, + overrideAccess: true, + }), + ) +} + +const post = (body: unknown, headers: Record = {}): Promise => + postSelfie( + new Request('http://localhost/api/fotos/selfie', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), + ) + +describe('selfie search endpoint (C234)', () => { + let specsLease: Awaited> + + beforeAll(async () => { + payload = await getPayload({ config: await config }) + specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY) + await ensureLeasedConsent(payload, { + consentKey: FACE_SEARCH_CONSENT_KEY, + leaseKey: FACE_SEARCH_LEASE_KEY, + }) + await ensureLeasedConsent(payload, { + consentKey: FACE_INDEX_CONSENT_KEY, + leaseKey: FACE_INDEX_LEASE_KEY, + }) + await setSelfieSearchEnabled(false) + }) + + 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 setSelfieSearchEnabled(false) + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + await specsLease.release() + }) + + it('answers closed (404) while the album kill switch is off, before anything else', async () => { + const response = await post({ vector: faceTestVector(0), intent: 'search' }) + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ ok: false, error: 'closed' }) + }) + + it('refuses a cross-origin request and a malformed body', async () => { + const crossOrigin = await post( + { vector: faceTestVector(0), intent: 'search' }, + { origin: 'https://malicioso.example' }, + ) + expect(crossOrigin.status).toBe(403) + + await setSelfieSearchEnabled(true) + const shortVector = await post({ vector: [0, 1], intent: 'search' }) + expect(shortVector.status).toBe(400) + + const badIntent = await post({ vector: faceTestVector(0), intent: 'enroll' }) + expect(badIntent.status).toBe(400) + + const extraKey = await post({ vector: faceTestVector(0), intent: 'search', image: 'x' }) + expect(extraKey.status).toBe(400) + + const notJson = await postSelfie( + new Request('http://localhost/api/fotos/selfie', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{nope', + }), + ) + expect(notJson.status).toBe(400) + await setSelfieSearchEnabled(false) + }) + + it('fails closed (503) while the query 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 { + await ensureLeasedConsent(payload, { + consentKey: FACE_SEARCH_CONSENT_KEY, + leaseKey: FACE_SEARCH_LEASE_KEY, + }) + await setSelfieSearchEnabled(false) + } + }) + + it('returns only the approved photos of the matched subject, 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] }, + overrideAccess: true, + }) + + await setSelfieSearchEnabled(true) + const response = await post({ vector, intent: 'search' }) + expect(response.status).toBe(200) + + const body = (await response.json()) as { + ok: boolean + found: boolean + photos: { id: number }[] + } + expect(body.ok).toBe(true) + expect(body.found).toBe(true) + 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. + 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 setSelfieSearchEnabled(true) + const before = await post({ vector, intent: 'search' }) + await expect(before.json()).resolves.toMatchObject({ found: true }) + + 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, + overrideAccess: true, + }) + expect(stored.status).toBe('removed') + expect(stored.vector).toBeNull() + + const after = await post({ vector, intent: 'search' }) + await expect(after.json()).resolves.toEqual({ ok: true, found: false }) + + // A descriptor that matches nobody cannot remove anybody. + const nobody = await post({ vector: faceTestVector(1), intent: 'leave-index' }) + await expect(nobody.json()).resolves.toEqual({ ok: true, removed: false }) + + await setSelfieSearchEnabled(false) + }) + + it('throttles the anonymous client well before the beacon budget', async () => { + const headers = { 'x-forwarded-for': '198.51.100.77' } + for (let attempt = 0; attempt < 20; attempt += 1) { + const response = await post({ vector: faceTestVector(0), intent: 'search' }, headers) + expect(response.status).toBe(404) + } + const throttled = await post({ vector: faceTestVector(0), intent: 'search' }, headers) + expect(throttled.status).toBe(429) + await expect(throttled.json()).resolves.toEqual({ ok: false, error: 'rate-limited' }) + }) + + 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, + }) + + await setSelfieSearchEnabled(true) + const response = await post({ vector, intent: 'search' }) + await expect(response.json()).resolves.toEqual({ ok: true, found: false }) + await setSelfieSearchEnabled(false) + }) +}) diff --git a/tests/int/faceSubjectIndex.int.spec.ts b/tests/int/faceSubjectIndex.int.spec.ts new file mode 100644 index 00000000..87e4bdde --- /dev/null +++ b/tests/int/faceSubjectIndex.int.spec.ts @@ -0,0 +1,249 @@ +// @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() +const createdSubjectIds = new Set() +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> + + 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([]) + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 2423249b..92001e36 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -214,6 +214,10 @@ describe('campaign JSON mutation route convention', () => { 'src/app/(frontend)/api/content-events/route.ts', 'anonymous public beacon (C213): reads no cookie and no session, so there is no CSRF surface to guard — the write is gated by the published-slug read and throttled in memory; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply', ], + [ + 'src/app/(frontend)/api/fotos/selfie/route.ts', + 'anonymous public selfie search (C234): reads no cookie and no session, so there is no CSRF surface to guard — the request is bounded/parsed strictly, throttled in memory and gated server-side by the album kill switch + Consent; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply', + ], [ 'src/app/(campaign)/campanha/api/ai-chat/route.ts', 'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path', @@ -438,6 +442,10 @@ describe('src/utilities top-level is pinned', () => { 'activityUi.ts', 'activityViewModels.ts', 'advisorData.ts', + // C234 — bounded streaming body reader shared by the anonymous JSON routes + // (content-events beacon + selfie search); extracted from the beacon route + // when it earned a second caller. + 'boundedRequestBody.ts', 'calendarFeed.ts', 'campaignAccess.ts', 'campaignActionContext.ts', diff --git a/tests/unit/contentEventRateLimit.unit.spec.ts b/tests/unit/contentEventRateLimit.unit.spec.ts index 9209df07..0ab12ed1 100644 --- a/tests/unit/contentEventRateLimit.unit.spec.ts +++ b/tests/unit/contentEventRateLimit.unit.spec.ts @@ -58,4 +58,16 @@ describe('checkContentEventRateLimit', () => { vi.setSystemTime(new Date('2026-09-23T12:10:00.001Z')) expect(checkContentEventRateLimit(key)).toBe(true) }) + + it('honours a stricter caller budget on the same registry (C234 selfie search)', () => { + const key = 'unit-strict-budget-client' + for (let attempt = 0; attempt < 20; attempt += 1) { + expect(checkContentEventRateLimit(key, 20)).toBe(true) + } + expect(checkContentEventRateLimit(key, 20)).toBe(false) + + // Another client still has its own window and the default budget is intact. + expect(checkContentEventRateLimit(`${key}-other`, 20)).toBe(true) + expect(checkContentEventRateLimit('unit-default-budget-client')).toBe(true) + }) }) diff --git a/tests/unit/e2eAffectedManifest.unit.spec.ts b/tests/unit/e2eAffectedManifest.unit.spec.ts index 9408a6d7..2fd2e700 100644 --- a/tests/unit/e2eAffectedManifest.unit.spec.ts +++ b/tests/unit/e2eAffectedManifest.unit.spec.ts @@ -83,6 +83,11 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => { // overlay, media route) is a new contract and the migration makes every // PR of this delivery high-risk (curated only). 'frontendFotos', + // C234 — deliberate: the selfie search page/endpoint (Consent + // fail-closed, only consented subjects, only approved photos) is a new + // contract and the migration makes every PR of this delivery high-risk + // (curated only). + 'frontendFotosSelfie', ]) const onDisk = new Set(specNamesOnDisk()) for (const spec of E2E_CURATED_SPECS) { diff --git a/tests/unit/faceCli.unit.spec.ts b/tests/unit/faceCli.unit.spec.ts new file mode 100644 index 00000000..c83aada8 --- /dev/null +++ b/tests/unit/faceCli.unit.spec.ts @@ -0,0 +1,139 @@ +// @vitest-environment node + +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +// C234 — the write guards of the two face 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 run = (scriptPath: string, args: string[], env: Record = {}) => + spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs', + NODE_ENV: 'production', + VITEST: '', + DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313', + PAYLOAD_SECRET: 'test-secret', + TEQO_ENV: '', + ALLOW_REMOTE_DB: '', + FACE_ENROLL_CONFIRM: '', + FACE_INDEX_CONFIRM: '', + S3_BUCKET: '', + S3_ENDPOINT: '', + S3_ACCESS_KEY_ID: '', + S3_SECRET_ACCESS_KEY: '', + ...env, + }, + }) + +const output = (result: ReturnType) => `${result.stdout}${result.stderr}` +const withS3 = { + S3_BUCKET: 'bucket', + S3_ENDPOINT: 'http://127.0.0.1:3900', + S3_ACCESS_KEY_ID: 'key', + 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)', () => { + it('prints the help even without a database', () => { + const result = run(indexScript, ['--help'], { DATABASE_URL: '' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('FACE_INDEX_CONFIRM') + expect(result.stdout).toContain('--refresh') + }) + + it('refuses --apply on a production target without the intent flag', () => { + const result = run(indexScript, ['--apply']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('FACE_INDEX_CONFIRM=1') + }) + + it('requires the declared TEQO_ENV once the intent flag is set', () => { + const result = run(indexScript, ['--apply'], { FACE_INDEX_CONFIRM: '1' }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('TEQO_ENV') + }) + + it('refuses --apply without the complete S3_* set on a declared production target', () => { + const result = run(indexScript, ['--apply'], { + FACE_INDEX_CONFIRM: '1', + TEQO_ENV: 'production', + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('S3_BUCKET') + }) + + it('reaches the payload bootstrap with the complete target declared', () => { + const result = run(indexScript, ['--apply'], { + FACE_INDEX_CONFIRM: '1', + TEQO_ENV: 'production', + ...withS3, + }) + + // No production database here: the run must fail while connecting (or + // right after), never with one of the write guards. + expect(result.status).toBe(1) + expect(output(result)).not.toContain('FACE_INDEX_CONFIRM=1') + expect(output(result)).not.toContain('TEQO_ENV=') + }) +}) diff --git a/tests/unit/faceEnrollPlan.unit.spec.ts b/tests/unit/faceEnrollPlan.unit.spec.ts new file mode 100644 index 00000000..46a933b3 --- /dev/null +++ b/tests/unit/faceEnrollPlan.unit.spec.ts @@ -0,0 +1,128 @@ +// @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') + }) +}) diff --git a/tests/unit/faceIndexPlan.unit.spec.ts b/tests/unit/faceIndexPlan.unit.spec.ts new file mode 100644 index 00000000..0a735378 --- /dev/null +++ b/tests/unit/faceIndexPlan.unit.spec.ts @@ -0,0 +1,176 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + FACE_INDEX_DEFAULT_OUT_DIR, + faceIndexReportStamp, + formatFaceIndexInventory, + formatFaceIndexReport, + parseFaceIndexCliArgs, + summarizeFaceIndexInventory, + summarizeFaceIndexResults, +} from '../../scripts/lib/faceIndexPlan.mjs' + +// C234 — the pure parser/summaries of `pnpm faces:index`: the honest staleness +// accounting and the operator-facing lines. + +describe('parseFaceIndexCliArgs', () => { + it('defaults to the dry-run and parses the modes', () => { + expect(parseFaceIndexCliArgs([])).toEqual({ + apply: false, + verify: false, + limit: null, + refresh: false, + out: FACE_INDEX_DEFAULT_OUT_DIR, + help: false, + }) + + const apply = parseFaceIndexCliArgs(['--apply', '--limit', '5', '--refresh', '--out', 'x']) + expect(apply.apply).toBe(true) + expect(apply.limit).toBe(5) + expect(apply.refresh).toBe(true) + expect(apply.out).toBe('x') + + expect(parseFaceIndexCliArgs(['--verify']).verify).toBe(true) + expect(parseFaceIndexCliArgs(['--help']).help).toBe(true) + }) + + it('fails closed on mutual exclusion, bad limit and escaping out', () => { + expect(() => parseFaceIndexCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/) + expect(() => parseFaceIndexCliArgs(['--limit', '0'])).toThrow(/--limit/) + expect(() => parseFaceIndexCliArgs(['--limit', 'x'])).toThrow(/--limit/) + expect(() => parseFaceIndexCliArgs(['--out', '../etc'])).toThrow(/--out/) + expect(() => parseFaceIndexCliArgs(['--refresh=1'])).toThrow(/argumento desconhecido/) + expect(() => parseFaceIndexCliArgs(['--limit'])).toThrow(/faltou valor/) + }) +}) + +describe('summarizeFaceIndexResults', () => { + it('counts indexed/linked/failed and aggregates links per subject', () => { + const summary = summarizeFaceIndexResults([ + { photoId: 1, status: 'indexed', matchedSubjects: [3, 4] }, + { photoId: 2, status: 'indexed', matchedSubjects: [3] }, + { photoId: 3, status: 'indexed', matchedSubjects: [] }, + { photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' }, + ]) + + expect(summary.indexed).toBe(3) + 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', () => { + const inventory = summarizeFaceIndexInventory({ + queue, + subjects, + currentConsentHash: 'hash-atual', + }) + + expect(inventory).toEqual({ + indexKey: 'k', + totalApproved: 10, + indexed: 6, + stale: 4, + eligibleSubjects: 2, + activeSubjects: 2, + removedSubjects: 1, + staleConsentSubjects: 1, + linkedPhotos: 2, + linksBySubject: [{ subjectId: 1, photos: 2 }], + }) + }) + + it('treats a missing current consent as stale for everyone', () => { + const inventory = summarizeFaceIndexInventory({ + queue, + subjects, + currentConsentHash: null, + }) + expect(inventory.staleConsentSubjects).toBe(2) + }) +}) + +describe('formatting', () => { + it('stamps the receipt name and prints the inventory lines', () => { + expect(faceIndexReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z') + + const lines = formatFaceIndexInventory( + { + indexKey: 'k', + totalApproved: 10, + indexed: 6, + stale: 4, + eligibleSubjects: 2, + activeSubjects: 2, + removedSubjects: 1, + staleConsentSubjects: 1, + linkedPhotos: 2, + linksBySubject: [{ subjectId: 1, photos: 2 }], + }, + '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)') + }) + + it('prints the apply summary with failures', () => { + const report = { + mode: 'apply', + target: 'host/db', + model: 'face-api@1.7.15/faceRecognitionNet', + queue: { items: 3, totalApproved: 10, eligibleSubjects: 1 }, + summary: { + indexed: 2, + failed: 1, + linkedPhotos: 1, + linksBySubject: [{ subjectId: 3, photos: 1 }], + 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('✗ foto 4 [analyze]: engine fora') + }) +}) diff --git a/tests/unit/faceSearch.unit.spec.ts b/tests/unit/faceSearch.unit.spec.ts new file mode 100644 index 00000000..08049e1e --- /dev/null +++ b/tests/unit/faceSearch.unit.spec.ts @@ -0,0 +1,180 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog' +import { + FACE_SEARCH_DESCRIPTOR_LENGTH, + FACE_SEARCH_MAX_DISTANCE, + FACE_SEARCH_MODEL, + faceEuclideanDistance, + faceStubDescriptorFromBytes, + faceSubjectIsEligible, + findFaceMatch, + 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. + +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) + }) + + it('fails closed on anything else', () => { + expect(readFaceVector(null)).toBeNull() + expect(readFaceVector('vector')).toBeNull() + expect(readFaceVector([1, 2, 3])).toBeNull() + expect(readFaceVector(vectorOf(0).slice(1))).toBeNull() + expect(readFaceVector([...vectorOf(0).slice(1), Number.NaN])).toBeNull() + expect(readFaceVector([...vectorOf(0).slice(1), Number.POSITIVE_INFINITY])).toBeNull() + expect(readFaceVector([...vectorOf(0).slice(1), '1'])).toBeNull() + }) +}) + +describe('faceEuclideanDistance', () => { + it('is the plain euclidean distance of same-length descriptors', () => { + expect(faceEuclideanDistance([0, 0], [0, 0])).toBe(0) + expect(faceEuclideanDistance([0, 0], [3, 4])).toBe(5) + }) + + it('is Infinity when the descriptors cannot be compared', () => { + expect(faceEuclideanDistance([0, 0], [0])).toBe(Number.POSITIVE_INFINITY) + expect(faceEuclideanDistance([], [])).toBe(Number.POSITIVE_INFINITY) + }) +}) + +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('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) + }) + + 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('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() + + 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() + }) +}) + +describe('toFaceSearchPhotoView', () => { + const item: ArchivePhotoPublicItem = { + id: 7, + alt: 'Plenária na Câmara', + title: 'Plenária pela saúde', + takenOn: '2026-09-12T12:00:00.000Z', + dateLabel: '12 de setembro de 2026', + shortDateLabel: '12 set 2026', + municipalityName: 'Feira de Santana', + municipalitySlug: 'feira-de-santana', + scene: 'plenaria', + sceneLabel: 'Plenária', + people: ['Jorge Solla', 'Outra Pessoa'], + peopleLabel: 'Jorge Solla e Outra Pessoa', + metaLabel: '12 set 2026 · Feira de Santana · Plenária', + searchText: 'plenaria feira de santana', + thumbnailPath: '/fotos/7/midia?tamanho=grade', + mediaPath: '/fotos/7/midia', + downloadPath: '/fotos/7/midia?download=1', + downloadFilename: 'jorge-solla-1313-foto-7.jpg', + } + + it('carries the photo context but never third-party names', () => { + const view = toFaceSearchPhotoView(item) as Record + + expect(view.id).toBe(7) + expect(view.title).toBe('Plenária pela saúde') + expect(view.metaLabel).toBe('12 set 2026 · Feira de Santana · Plenária') + expect(view.thumbnailPath).toBe('/fotos/7/midia?tamanho=grade') + + expect(view).not.toHaveProperty('people') + expect(view).not.toHaveProperty('peopleLabel') + expect(Object.keys(view).join(' ')).not.toMatch(/score|similar|distance|percent/i) + }) +}) + +describe('faceStubDescriptorFromBytes', () => { + it('is deterministic, descriptor-sized and bounded', () => { + const bytes = Uint8Array.from([1, 2, 3, 4, 5]) + const first = faceStubDescriptorFromBytes(bytes) + const second = faceStubDescriptorFromBytes(bytes) + + expect(first).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH) + expect(first).toEqual(second) + expect(first.every((value) => value >= -1 && value <= 1)).toBe(true) + }) + + it('derives different descriptors for different bytes', () => { + const a = faceStubDescriptorFromBytes(Uint8Array.from([1, 2, 3])) + const b = faceStubDescriptorFromBytes(Uint8Array.from([3, 2, 1])) + expect(a).not.toEqual(b) + expect(faceEuclideanDistance(a, b)).toBeGreaterThan(FACE_SEARCH_MAX_DISTANCE) + }) +}) diff --git a/tests/unit/faceSearchRequest.unit.spec.ts b/tests/unit/faceSearchRequest.unit.spec.ts new file mode 100644 index 00000000..9ccd2666 --- /dev/null +++ b/tests/unit/faceSearchRequest.unit.spec.ts @@ -0,0 +1,52 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch' +import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch' + +// C234 — the wire contract of the selfie endpoint: strict object, exact +// descriptor length, finite numbers only and the two intents. Everything else +// is a malformed body (never a silently ignored field). + +const vector = (): number[] => Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => 0.5) + +describe('faceSearchRequestSchema', () => { + it('accepts both intents with a full descriptor', () => { + expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search' }).success).toBe( + true, + ) + expect( + faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'leave-index' }).success, + ).toBe(true) + }) + + it('rejects unknown intents and extra keys', () => { + expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'enroll' }).success).toBe( + false, + ) + expect( + faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search', image: 'x' }).success, + ).toBe(false) + }) + + it('rejects descriptors of the wrong length or with non-finite values', () => { + expect( + faceSearchRequestSchema.safeParse({ vector: vector().slice(1), intent: 'search' }).success, + ).toBe(false) + expect( + faceSearchRequestSchema.safeParse({ + vector: [...vector().slice(1), Number.NaN], + intent: 'search', + }).success, + ).toBe(false) + expect( + faceSearchRequestSchema.safeParse({ + vector: [...vector().slice(1), Number.POSITIVE_INFINITY], + intent: 'search', + }).success, + ).toBe(false) + expect(faceSearchRequestSchema.safeParse({ vector: {}, intent: 'search' }).success).toBe(false) + expect(faceSearchRequestSchema.safeParse({ intent: 'search' }).success).toBe(false) + }) +})