feat(C139): schemas e actions de contato — criação staff e edição por célula

Fase 3 (server) do plano aprovado:
- schemas/contact.ts: contactCreateSchema (nome único via invariante, state
  obrigatório, phones opcionais C112); contactFieldUpdateSchema ganha variantes
  city e postalCode ('' → clear); mensagens das actions client-safe aqui.
- schemas/personGenders.ts: enum de gênero em módulo folha (ciclo de imports
  contact ↔ leadership detectado e quebrado).
- actions/contact.ts: createContact (gate staff + bypass justificado, lock
  contact-name:{normalized}) e updateContactField (escopo 'edita o que vê'
  via re-check user-threaded para advisor; reorder de telefone primário).
  Apagar reusa as actions C100 existentes.
- collections Contact e Supporter: label de gênero 'Outro' → 'Não binário'
  (sem migration; nenhum teste pina a label).
- 11 testes unit novos; lint, tsc e prettier limpos.
This commit is contained in:
Francisco Solla 2026-08-13 14:12:53 -03:00
parent 1daaaf9f4e
commit e71b062650
11 changed files with 392 additions and 51 deletions

View file

@ -0,0 +1,170 @@
'use server'
import { revalidatePath } from 'next/cache'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import {
CONTACT_CELL_NOT_IN_SCOPE_MESSAGE,
CONTACT_CELL_STAFF_MESSAGE,
CONTACT_CREATE_STAFF_MESSAGE,
contactCreateSchema,
contactFieldUpdateSchema,
type ContactCreateInput,
type ContactFieldUpdateInput,
} from '@/lib/schemas/contact'
import type { CampaignUser, Contact } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { assertContactNameAvailable } from '@/utilities/contacts/contactNameInvariant'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import type { Payload } from 'payload'
/**
* C139 — the contacts page writes the ficha itself, so the write gate is the
* row's own read visibility: "you edit what you see". The `contact` collection
* access keeps `create`/`update` admin-only (`canManageContacts`), so every
* campaign write bypasses with `overrideAccess: true` AFTER the staff gate and
* (for an advisor) a user-threaded scope re-check — the bypass is justified by
* the gate, never standalone.
*/
const assertContactRowEditable = async (
payload: Payload,
actor: CampaignUser,
contactID: number,
req: PayloadTransactionRequest,
): Promise<void> => {
if (isCampaignUnrestricted(actor)) return
const visible = await payload.find({
collection: 'contact',
where: { id: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
req,
})
if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE)
}
export const createContactRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactCreateInput,
): Promise<{ contactID: number }> => {
const data = contactCreateSchema.parse(input)
return withPayloadTransaction(payload, async ({ req }) => {
await reloadStaffActor(payload, actor, CONTACT_CREATE_STAFF_MESSAGE, req)
await assertContactNameAvailable(payload, req, data.name)
const created = await payload.create({
collection: 'contact',
data: {
name: data.name,
...(data.email ? { email: data.email } : {}),
...(data.phones && data.phones.length > 0
? { phones: data.phones.map((value) => ({ value })) }
: {}),
...(data.gender ? { gender: data.gender } : {}),
state: data.state,
...(data.city ? { city: data.city } : {}),
...(data.postalCode ? { postalCode: data.postalCode } : {}),
},
depth: 0,
// Intentional bypass: the staff gate above is the authorization; the
// collection's `create` access is admin-only by design (C139).
overrideAccess: true,
req,
})
return { contactID: created.id }
})
}
export const createContact = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
const { contactID } = await createContactRecord(payload, actor, input as ContactCreateInput)
revalidatePath('/campanha/contatos', 'page')
return { contactID }
}
const contactFieldData = (
data: ContactFieldUpdateInput,
): Partial<Pick<Contact, 'name' | 'email' | 'city' | 'postalCode'>> & {
phones?: { value: string }[]
} => {
switch (data.field) {
case 'name':
return { name: data.name }
case 'email':
return { email: data.email ?? null }
case 'city':
return { city: data.city }
case 'postalCode':
// The schema transforms '' → undefined: clearing the CEP stores null.
return { postalCode: data.postalCode ?? null }
case 'phones':
return { phones: data.phones.map((value) => ({ value })) }
case 'phone':
return {
phones: data.phone === null || data.phone === undefined ? [] : [{ value: data.phone }],
}
}
}
export const updateContactFieldRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFieldUpdateInput,
): Promise<void> => {
const data = contactFieldUpdateSchema.parse(input)
return withPayloadTransaction(payload, async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, CONTACT_CELL_STAFF_MESSAGE, req)
await assertContactRowEditable(payload, currentActor, data.id, req)
let contactData = contactFieldData(data)
if (data.field === 'name') {
await assertContactNameAvailable(payload, req, data.name, data.id)
} else if (data.field === 'phone') {
// Inline primary-phone edit (C112 shape): the new number goes first and
// the rest shifts up; clearing drops the primary phone.
const current = await payload.findByID({
collection: 'contact',
id: data.id,
depth: 0,
select: { phones: true },
// Intentional bypass: the scope check above established the actor's
// right over this row; the read only resolves the list to reorder.
overrideAccess: true,
req,
})
contactData = {
phones: reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({ value })),
}
}
// Intentional bypass: same gate as the reads above.
await payload.update({
collection: 'contact',
id: data.id,
data: contactData,
depth: 0,
overrideAccess: true,
req,
})
})
}
export const updateContactField = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
await updateContactFieldRecord(payload, actor, input as ContactFieldUpdateInput)
revalidatePath('/campanha/contatos', 'page')
}

View file

@ -162,7 +162,7 @@ export const Contact: CollectionConfig = {
options: [
{ label: 'Feminino', value: 'feminino' },
{ label: 'Masculino', value: 'masculino' },
{ label: 'Outro', value: 'outro' },
{ label: 'Não binário', value: 'outro' },
{ label: 'Não informado', value: 'nao_informado' },
],
},

View file

@ -109,7 +109,7 @@ export const Supporter: CollectionConfig = {
{ label: 'Certo', value: 'certo' },
{ label: 'Tende a certo', value: 'tende_a_certo' },
{ label: 'Indeciso', value: 'indeciso' },
{ label: 'Outro', value: 'outro' },
{ label: 'Não binário', value: 'outro' },
],
},
{

View file

@ -1,5 +1,6 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { personGenders } from '@/lib/schemas/personGenders'
import { z } from 'zod'
import {
@ -10,34 +11,25 @@ import {
export type StateKey = keyof typeof CitiesByState
export const contactSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(120, 'Nome deve ter no máximo 100 caracteres')
.regex(
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
),
email: z.email('Email inválido'),
phone: z
.string()
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
postalCode: z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.transform((v) => (v === '' ? undefined : v))
.optional(),
})
/** Gender enum shared with the leadership collection (same values, C139). */
export const contactGenders = personGenders
const contactNameSchema = z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(120, 'Nome deve ter no máximo 100 caracteres')
.regex(
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
)
const contactPostalCodeSchema = z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.transform((v) => (v === '' ? undefined : v))
.optional()
const contactPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
@ -59,6 +51,55 @@ export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones,
}
})
export const contactSchema = z.object({
name: contactNameSchema,
email: z.email('Email inválido'),
phone: z
.string()
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
postalCode: contactPostalCodeSchema,
})
/**
* C139 — the create form of the contacts page: the ficha fields a staff actor
* fills in one shot (name required and unique — the invariant runs in the
* action under an advisory lock; phones follow the C112 shape).
*/
export const contactCreateSchema = z.object({
name: contactNameSchema,
email: optionalPersistedEmail,
// Optional like the collection: name-only fichas exist (C111); the form
// sends [] when the actor typed no phone.
phones: contactPhonesSchema.optional(),
gender: z.enum(contactGenders).optional(),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa').optional(),
postalCode: z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.optional(),
})
export type ContactCreateInput = z.input<typeof contactCreateSchema>
export const CONTACT_NAME_CONFLICT_MESSAGE =
'Já existe um contato com este nome — confira a lista antes de salvar.'
export const CONTACT_CELL_STAFF_MESSAGE = 'Apenas a equipe pode editar fichas.'
export const CONTACT_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta ficha não está no seu escopo.'
export const CONTACT_CREATE_STAFF_MESSAGE = 'Apenas a equipe pode criar contatos.'
export const CONTACT_CREATED_MESSAGE = 'Contato criado.'
/** Per-field Contact write shared by campaign person joins. */
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
@ -81,6 +122,19 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
field: z.literal('phones'),
phones: contactPhonesSchema,
}),
// C139 — the contacts page edits the ficha's own fields in place: city and
// CEP are free text on the Contact collection itself (no person-join
// sibling writes them).
z.object({
id: positiveRelationshipId,
field: z.literal('city'),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
}),
z.object({
id: positiveRelationshipId,
field: z.literal('postalCode'),
postalCode: contactPostalCodeSchema,
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>

View file

@ -1,6 +1,7 @@
import { z } from 'zod'
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
import { personGenders } from '@/lib/schemas/personGenders'
import {
optionalPersistedEmail,
positiveRelationshipId,
@ -8,7 +9,7 @@ import {
trimmedOptionalText,
} from '@/lib/schemas/primitives'
export const leadershipGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
export const leadershipGenders = personGenders
export const leadershipSupportStatuses = [
'engajado',

View file

@ -0,0 +1,9 @@
/**
* The gender enum shared by every person-ish collection (contact, leadership,
* dobradinha, supporter). Leaf module on purpose: `contact.ts` and
* `leadership.ts` schemas import it from here — a re-export through either
* would be a module cycle.
*/
export const personGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
export type PersonGender = (typeof personGenders)[number]

View file

@ -36,7 +36,6 @@ export const contactGenderLabels: Record<ContactGender, string> = {
outro: 'Não binário',
nao_informado: 'Não informado',
}
export const CONTACT_ABSENCES = ['sem_telefone', 'sem_email'] as const
export type ContactAbsence = (typeof CONTACT_ABSENCES)[number]

View file

@ -2,11 +2,9 @@ import 'server-only'
import type { Payload, PayloadRequest } from 'payload'
import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
export const CONTACT_NAME_CONFLICT_MESSAGE =
'Já existe um contato com este nome — confira a lista antes de salvar.'
type ContactNameRequest = Pick<PayloadRequest, 'transactionID'>
/**

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Contact } from '@/payload-types'
import {
contactFilterFacetsFromRows,
filterContactRows,
@ -56,7 +58,7 @@ describe('toContactRowViewModel', () => {
state: 'BA',
city: 'Camaçari',
postalCode: '42800-000',
} as never)
} as unknown as Contact)
expect(view).toEqual({
contactID: 7,
name: ' Maria ',
@ -80,7 +82,7 @@ describe('toContactRowViewModel', () => {
state: 'BAHIA',
city: null,
postalCode: null,
} as never)
} as unknown as Contact)
expect(view).toEqual({
contactID: 8,
name: 'Sem Dados',

View file

@ -1,8 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import type { Payload } from 'payload'
import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact'
import {
assertContactNameAvailable,
CONTACT_NAME_CONFLICT_MESSAGE,
normalizeContactName,
} from '@/utilities/contacts/contactNameInvariant'
@ -15,28 +17,31 @@ describe('normalizeContactName', () => {
})
describe('assertContactNameAvailable', () => {
const makePayload = (docs: { name: string; id?: number }[]) => ({
type WhereClause = {
and?: { name?: { like?: string }; id?: { not_equals?: number } }[]
}
const makePayload = (docs: { name: string; id?: number }[]): Pick<Payload, 'db' | 'find'> => ({
db: {
name: 'postgres',
sessions: { tx: { db: { execute: vi.fn() } } },
},
find: vi.fn().mockImplementation(async ({ where }: { where: any }) => {
const conditions: any[] = where?.and ?? []
const idExclusion = conditions.find((c) => c.id?.not_equals)?.id.not_equals
const like = conditions.find((c) => c.name?.like)?.name.like as string | undefined
} as unknown as Payload['db'],
find: vi.fn().mockImplementation(async ({ where }: { where?: WhereClause }) => {
const conditions = where?.and ?? []
const idExclusion = conditions.find((c) => c.id?.not_equals)?.id?.not_equals
const like = conditions.find((c) => c.name?.like)?.name?.like
const filtered = docs.filter((doc) => {
if (idExclusion !== undefined && doc.id === idExclusion) return false
if (like && !doc.name.toLowerCase().includes(like)) return false
return true
})
return { docs: filtered, totalDocs: filtered.length }
}),
}) as unknown as Payload['find'],
})
it('passes when no stored contact normalizes to the same name', async () => {
const payload = makePayload([{ id: 9, name: 'Maria Souza' }])
await expect(
assertContactNameAvailable(payload as never, { transactionID: 'tx' }, ' maria silva '),
assertContactNameAvailable(payload, { transactionID: 'tx' }, ' maria silva '),
).resolves.toBeUndefined()
// The DB prefilter runs the case-insensitive substring, the verdict is in memory.
expect(payload.find).toHaveBeenCalledOnce()
@ -44,20 +49,18 @@ describe('assertContactNameAvailable', () => {
it('throws the conflict message on a normalized-name match (case/whitespace insensitive)', async () => {
const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
await expect(assertContactNameAvailable(payload as never, {}, 'MARIA SILVA')).rejects.toThrow(
await expect(assertContactNameAvailable(payload, {}, 'MARIA SILVA')).rejects.toThrow(
CONTACT_NAME_CONFLICT_MESSAGE,
)
})
it('ignores the contact itself when renaming (contactID excluded)', async () => {
const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
await expect(
assertContactNameAvailable(payload as never, {}, 'Maria Silva', 9),
).resolves.toBeUndefined()
await expect(assertContactNameAvailable(payload, {}, 'Maria Silva', 9)).resolves.toBeUndefined()
})
it('does not collide on a partial-name substring match', async () => {
const payload = makePayload([{ id: 9, name: 'Ana Maria' }])
await expect(assertContactNameAvailable(payload as never, {}, 'Maria')).resolves.toBeUndefined()
await expect(assertContactNameAvailable(payload, {}, 'Maria')).resolves.toBeUndefined()
})
})

View file

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { contactCreateSchema, contactFieldUpdateSchema } from '@/lib/schemas/contact'
describe('contactCreateSchema (C139)', () => {
it('accepts a full valid ficha', () => {
const result = contactCreateSchema.safeParse({
name: 'Maria Silva',
email: 'maria@example.com',
phones: ['71999990000'],
gender: 'feminino',
state: 'BA',
city: 'Salvador',
postalCode: '40000000',
})
expect(result.success).toBe(true)
})
it('accepts the minimal ficha (name + state)', () => {
const result = contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA' })
expect(result.success).toBe(true)
})
it('rejects a missing state', () => {
const result = contactCreateSchema.safeParse({ name: 'Ana Souza' })
expect(result.success).toBe(false)
if (!result.success)
expect(result.error.issues.some((issue) => issue.path[0] === 'state')).toBe(true)
})
it('rejects an unknown gender', () => {
expect(
contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', gender: 'x' }).success,
).toBe(false)
})
it('rejects a single-word name', () => {
expect(contactCreateSchema.safeParse({ name: 'Ana', state: 'BA' }).success).toBe(false)
})
it('rejects duplicated phones within the same ficha (C112)', () => {
const result = contactCreateSchema.safeParse({
name: 'Ana Souza',
state: 'BA',
phones: ['71999990000', '71999990000'],
})
expect(result.success).toBe(false)
if (!result.success) {
expect(
result.error.issues.some((issue) => issue.message === BRAZILIAN_PHONE_DUPLICATE_MESSAGE),
).toBe(true)
}
})
it('accepts an empty phone list', () => {
expect(
contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', phones: [] }).success,
).toBe(true)
})
})
describe('contactFieldUpdateSchema — C139 variants', () => {
it('accepts the city variant', () => {
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'city', city: 'Camaçari' }).success,
).toBe(true)
expect(contactFieldUpdateSchema.safeParse({ id: 5, field: 'city', city: 'C' }).success).toBe(
false,
)
})
it('transforms an empty postalCode to undefined (clear semantics)', () => {
const result = contactFieldUpdateSchema.safeParse({
id: 5,
field: 'postalCode',
postalCode: '',
})
expect(result.success).toBe(true)
if (result.success) {
const data = result.data as { field: string; postalCode?: string }
expect(data.postalCode).toBeUndefined()
}
})
it('accepts an 8-digit postalCode and rejects a malformed one', () => {
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'postalCode', postalCode: '42800000' })
.success,
).toBe(true)
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'postalCode', postalCode: 'abc' }).success,
).toBe(false)
})
it('keeps the legacy variants working', () => {
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'name', name: 'Ana Souza' }).success,
).toBe(true)
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'phones', phones: ['71999990000'] })
.success,
).toBe(true)
})
})