diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts new file mode 100644 index 00000000..abd1ec1d --- /dev/null +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -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 => { + 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> & { + 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 => { + 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') +} diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 743e3e7f..e7632362 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -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' }, ], }, diff --git a/src/collections/Supporter.ts b/src/collections/Supporter.ts index 260ada68..5a055963 100644 --- a/src/collections/Supporter.ts +++ b/src/collections/Supporter.ts @@ -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' }, ], }, { diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index 768fd0e4..01d06afe 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -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( - (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( + (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( + (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 + +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 diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 6a3aaa5f..2655b998 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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', diff --git a/src/lib/schemas/personGenders.ts b/src/lib/schemas/personGenders.ts new file mode 100644 index 00000000..168b2711 --- /dev/null +++ b/src/lib/schemas/personGenders.ts @@ -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] diff --git a/src/utilities/contacts/contactListUrl.ts b/src/utilities/contacts/contactListUrl.ts index 17a8f882..4445647b 100644 --- a/src/utilities/contacts/contactListUrl.ts +++ b/src/utilities/contacts/contactListUrl.ts @@ -36,7 +36,6 @@ export const contactGenderLabels: Record = { 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] diff --git a/src/utilities/contacts/contactNameInvariant.ts b/src/utilities/contacts/contactNameInvariant.ts index d3596285..ecffafd9 100644 --- a/src/utilities/contacts/contactNameInvariant.ts +++ b/src/utilities/contacts/contactNameInvariant.ts @@ -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 /** diff --git a/tests/unit/contactListData.unit.spec.ts b/tests/unit/contactListData.unit.spec.ts index e444dc92..e382f747 100644 --- a/tests/unit/contactListData.unit.spec.ts +++ b/tests/unit/contactListData.unit.spec.ts @@ -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', diff --git a/tests/unit/contactNameInvariant.unit.spec.ts b/tests/unit/contactNameInvariant.unit.spec.ts index c40bf05c..bec6e729 100644 --- a/tests/unit/contactNameInvariant.unit.spec.ts +++ b/tests/unit/contactNameInvariant.unit.spec.ts @@ -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 => ({ 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() }) }) diff --git a/tests/unit/contactSchemas.unit.spec.ts b/tests/unit/contactSchemas.unit.spec.ts new file mode 100644 index 00000000..25ef73ad --- /dev/null +++ b/tests/unit/contactSchemas.unit.spec.ts @@ -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) + }) +})