teqo-backup/src/collections/Contact.ts
Francisco Solla 398a91e54a feat(S9): captura de novidades da campanha na home pública (form + CTA do hero)
Seção 'Receba as novidades da campanha' acima do rodapé: nome + WhatsApp
obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado
'Quero fazer parte do time' gravado como subscription.campaignLevel
(time|esporadico). CTA secundário do hero vira o atalho (#novidades, scroll
suave). Contact + Subscription em withPayloadTransaction com consentimento
novo fail-closed por chave estável 'campanha-novidades' — nada é gravado sem o
texto aprovado no admin. Migration 20260819_192605 (state de contact nullable
+ coluna campaign_level). Impl plan, changelog, int + e2e.
2026-08-20 02:19:58 +00:00

196 lines
5.3 KiB
TypeScript

import { CitiesByState } from '@/lib/cities'
import {
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
normalizeBrazilianPhone,
} from '@/lib/phone'
import { trimmedText } from '@/lib/text'
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
type ContactPhoneValue = { value: string }
/**
* Normalizes the ficha's phone list: each entry is a valid Brazilian mobile
* (11 digits, `^[1-9]{2}9\d{8}$`), empty entries are dropped, and the same
* number cannot be stored twice in the same ficha (the person already has it;
* sharing a number BETWEEN fichas stays allowed — C111). `undefined` (admin
* untouched) passes through; `null`/`''` collapse to an empty list.
*/
const normalizeContactPhones = (data: unknown): ContactPhoneValue[] => {
const phones = Array.isArray(data) ? data : []
const seen = new Set<string>()
const normalized: ContactPhoneValue[] = []
for (const entry of phones) {
if (entry === null || entry === undefined) continue
const raw =
typeof entry === 'object' && 'value' in (entry as Record<string, unknown>)
? (entry as Record<string, unknown>).value
: entry
if (raw === null || raw === undefined || raw === '') continue
const input = String(raw)
let value: string | null
if (/^\d{11}$/.test(input)) {
if (/^[1-9]{2}9\d{8}$/.test(input)) {
value = input
} else {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
} else {
value = normalizeBrazilianPhone(input)
if (!value) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
}
if (seen.has(value)) {
throw new APIError(BRAZILIAN_PHONE_DUPLICATE_MESSAGE, 400)
}
seen.add(value)
normalized.push({ value })
}
return normalized
}
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (operation !== 'update' || !data || data.name === undefined || !originalDoc) return data
const currentName = trimmedText(originalDoc.name)
const nextName = trimmedText(data.name)
if (nextName === currentName) return data
const linkedDeputy = await req.payload.find({
collection: 'stateDeputy',
where: { contact: { equals: originalDoc.id } },
depth: 0,
limit: 1,
pagination: false,
// Intentional bypass: Contact access is broader than the deputy's scope; this
// lookup only determines whether the name invariant applies.
overrideAccess: true,
req,
})
const stateDeputyID = linkedDeputy.docs[0]?.id
if (stateDeputyID !== undefined) {
await assertStateDeputyNameAvailable(req.payload, req, nextName, stateDeputyID)
}
return data
}
export const Contact: CollectionConfig = {
slug: 'contact',
labels: {
singular: 'Contato',
plural: 'Contatos',
},
admin: {
group: 'Contatos',
useAsTitle: 'name',
},
access: {
create: canManageContacts,
read: canReadContacts,
update: canManageContacts,
delete: canManageContacts,
},
hooks: {
beforeValidate: [
({ data }) => {
if (!data || data.phones === undefined) return data
if (data.phones === null || data.phones === '') {
data.phones = []
return data
}
data.phones = normalizeContactPhones(data.phones)
return data
},
],
beforeChange: [enforceStateDeputyName],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
minLength: 2,
maxLength: 120,
required: true,
},
{
name: 'email',
type: 'email',
label: 'E-mail',
required: false,
},
{
name: 'phones',
type: 'array',
label: 'Telefones',
// Optional at the collection level so name-only records (e.g. leaderships imported
// from the projection sheet) can exist; UI flows still require it via zod schemas.
required: false,
fields: [
{
name: 'value',
type: 'text',
label: 'Celular',
minLength: 11,
maxLength: 11,
index: true,
},
],
},
{
name: 'gender',
type: 'select',
label: 'Gênero',
options: [
{ label: 'Feminino', value: 'feminino' },
{ label: 'Masculino', value: 'masculino' },
{ label: 'Não binário', value: 'outro' },
{ label: 'Não informado', value: 'nao_informado' },
],
},
{
name: 'state',
type: 'select',
label: 'Estado',
options: Object.keys(CitiesByState) as (keyof typeof CitiesByState)[],
// S9 — relaxed so the public campaign capture can store a contact
// without a state (optional field in that form). The staff ficha still
// requires it via `contactCreateSchema` — precedent `phones` (optional
// at the collection, zod-required in the flows).
required: false,
},
{
name: 'city',
type: 'text',
label: 'Cidade',
minLength: 2,
maxLength: 100,
required: false,
},
{
name: 'postalCode',
type: 'text',
label: 'CEP',
minLength: 8,
maxLength: 8,
},
],
}