diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx index d3d2ce30..451aae6d 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx @@ -9,6 +9,7 @@ import { assignNucleusCoordinatorsFormAction, loadCoordinatorAssignmentOptions, } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions' +import { loadNucleusShareRecipients } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions' import { confirmVoteEstimateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions' import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog' import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' @@ -16,6 +17,7 @@ import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssi import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog' import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab' import { NucleusTabNav } from '@/components/campaign/NucleusTabNav' +import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell' import { TseZoneBadge } from '@/components/campaign/TseZoneBadge' import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard' import { VoteEstimateDialogShell } from '@/components/campaign/VoteEstimateDialogShell' @@ -115,6 +117,11 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu
+ {canEdit ? ( +
+) + +const RecipientSection = ({ + title, + roleLabel, + recipients, + onOpenWhatsApp, +}: { + title: string + roleLabel: string + recipients: NucleusShareRecipient[] + onOpenWhatsApp: (recipient: NucleusShareRecipient) => void +}) => { + if (recipients.length === 0) return null + + return ( +
+

+ {title} +

+
+ {recipients.map((recipient) => ( + + ))} +
+
+ ) +} + +export const ShareNucleusDialog = ({ + data, + loadError, + loading, + nucleusName, + onRetry, + senderName, +}: { + data: NucleusShareRecipientsResult | null + loadError: boolean + loading: boolean + nucleusName: string + onRetry: () => void + senderName: string +}) => { + const [copied, setCopied] = useState(false) + const [copyError, setCopyError] = useState(null) + + const openWhatsApp = (recipient: NucleusShareRecipient) => { + if (!data) return + const message = buildNucleusShareMessage({ + recipientName: recipient.name, + senderName, + nucleusName, + nucleusUrl: data.nucleusUrl, + }) + window.open(buildWhatsAppUrl(recipient.phone, message), '_blank', 'noopener,noreferrer') + } + + const copyLink = async () => { + if (!data) return + setCopyError(null) + try { + await navigator.clipboard.writeText(data.nucleusUrl) + setCopied(true) + } catch { + setCopyError('Não foi possível copiar o link. Selecione e copie manualmente.') + } + } + + return ( + + + Compartilhar núcleo + + {nucleusName} · envia apenas o link, não concede acesso + + + + {loading ? ( +
+
+ ) : null} + + {loadError ? ( +
+ + Não foi possível carregar + + Não foi possível listar os destinatários. Tente novamente. + + + +
+ ) : null} + + {copyError ? ( + + Cópia indisponível + {copyError} + + ) : null} + + {data && !loading ? ( +
+ + + + +
+

+ Copiar link +

+
+
+
+ +
+
+
+ ) : null} +
+ ) +} diff --git a/src/components/campaign/ShareNucleusDialogShell.tsx b/src/components/campaign/ShareNucleusDialogShell.tsx new file mode 100644 index 00000000..79484084 --- /dev/null +++ b/src/components/campaign/ShareNucleusDialogShell.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Share2Icon } from 'lucide-react' + +import type { NucleusShareRecipientsResult } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions' +import { LazyDialogModuleFallback } from '@/components/campaign/LazyDialogModuleFallback' +import { useLazyDialogModule } from '@/components/campaign/useLazyDialogModule' +import { Button } from '@/components/ui/button' +import { Dialog, DialogTrigger } from '@/components/ui/dialog' + +type ShareNucleusDialogModule = { + default: typeof import('./ShareNucleusDialog').ShareNucleusDialog +} + +const loadShareNucleusDialog = (): Promise => + import('./ShareNucleusDialog').then((module) => ({ + default: module.ShareNucleusDialog, + })) + +export type LoadNucleusShareRecipientsAction = () => Promise + +export const ShareNucleusDialogShell = ({ + loadDialogModule = loadShareNucleusDialog, + loadRecipients, + nucleusName, + senderName, +}: { + loadDialogModule?: () => Promise + loadRecipients: LoadNucleusShareRecipientsAction + nucleusName: string + senderName: string +}) => { + const [activated, setActivated] = useState(false) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(false) + const requestId = useRef(0) + const { + component: DialogComponent, + load, + resetAfterClose, + status, + } = useLazyDialogModule(loadDialogModule) + + const fetchRecipients = useCallback(async () => { + const currentRequest = ++requestId.current + setLoading(true) + setLoadError(false) + setData(null) + try { + const result = await loadRecipients() + if (requestId.current !== currentRequest) return + setData(result) + } catch { + if (requestId.current !== currentRequest) return + setLoadError(true) + } finally { + if (requestId.current === currentRequest) setLoading(false) + } + }, [loadRecipients]) + + const openDialog = () => { + setActivated(true) + load() + } + + const closeDialog = () => { + setActivated(false) + setData(null) + setLoading(false) + setLoadError(false) + requestId.current += 1 + resetAfterClose() + } + + useEffect(() => { + if (!activated) return + void fetchRecipients() + return () => { + requestId.current += 1 + } + }, [activated, fetchRecipients]) + + return ( + { + if (open) { + openDialog() + return + } + closeDialog() + }} + > + + + + {activated ? ( + DialogComponent ? ( + { + load() + void fetchRecipients() + }} + senderName={senderName} + /> + ) : ( + + ) + ) : null} + + ) +} diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index 199b05e7..884b623f 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -377,7 +377,7 @@ export const getAccessibleNucleusIds = async ( return uniqueIDs } -export const canReadCampaignUserPhone: FieldAccess = async ({ id, req }) => { +export const canReadCampaignUserPhone: FieldAccess = async ({ doc, id, req }) => { if (isPayloadAdmin(req.user)) return true const currentUser = await getFreshCampaignUser(req) @@ -393,6 +393,28 @@ export const canReadCampaignUserPhone: FieldAccess = async ({ id, req }) => { const cacheKey = `${CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY}:${currentUser.id}:${targetUserID}` if (typeof context[cacheKey] === 'boolean') return context[cacheKey] + let targetRole = + typeof doc === 'object' && doc !== null && 'role' in doc ? doc.role : undefined + if (targetRole === undefined) { + try { + const target = await req.payload.findByID({ + collection: 'campaignUser', + id: targetUserID, + depth: 0, + select: { role: true }, + overrideAccess: true, + req, + }) + targetRole = target.role + } catch { + // Missing target only — fall through to the coordinator-scope check. + } + } + if (targetRole === 'geral') { + context[cacheKey] = true + return true + } + const nucleusIDs = await getAccessibleNucleusIds(req, currentUser) if (!nucleusIDs?.length) { context[cacheKey] = false diff --git a/src/utilities/nucleusShareRecipients.ts b/src/utilities/nucleusShareRecipients.ts new file mode 100644 index 00000000..3210ee34 --- /dev/null +++ b/src/utilities/nucleusShareRecipients.ts @@ -0,0 +1,123 @@ +import type { Payload } from 'payload' + +import type { CampaignUser, Contact } from '@/payload-types' +import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship' + +export type NucleusShareRecipient = { + id: number + name: string + phone: string +} + +export type NucleusShareRecipients = { + general: NucleusShareRecipient[] + coordinators: NucleusShareRecipient[] + leaderships: NucleusShareRecipient[] +} + +type CampaignUserShareDoc = { + id: number + name: string + phone?: string | null +} + +type LeadershipShareDoc = { + id: number + contact?: number | Contact | null +} + +const emptyCampaignUserDocs: CampaignUserShareDoc[] = [] +const emptyLeadershipDocs: LeadershipShareDoc[] = [] + +const toCampaignUserRecipient = (doc: CampaignUserShareDoc): NucleusShareRecipient | null => + typeof doc.phone === 'string' && doc.phone.length > 0 + ? { id: doc.id, name: doc.name, phone: doc.phone } + : null + +const toLeadershipRecipient = (doc: LeadershipShareDoc): NucleusShareRecipient | null => { + if (!isPopulatedRelationship(doc.contact)) return null + if (typeof doc.contact.phone !== 'string' || doc.contact.phone.length === 0) return null + return { + id: doc.id, + name: doc.contact.name, + phone: doc.contact.phone, + } +} + +export const getNucleusShareRecipients = async ( + payload: Payload, + user: CampaignUser, + slug: string, +): Promise => { + const nucleusResult = await payload.find({ + collection: 'electoralNucleus', + where: { slug: { equals: slug } }, + depth: 0, + limit: 1, + pagination: false, + select: { coordinators: true }, + user, + overrideAccess: false, + }) + const nucleus = nucleusResult.docs[0] + if (!nucleus) throw new Error('Núcleo não encontrado ou sem acesso.') + + const coordinatorIds = (nucleus.coordinators ?? []) + .map((coordinator) => relationshipId(coordinator)) + .filter((id): id is number => id !== null) + + // Privileged reads after the nucleus gate: avoids N+1 field-access phone checks + // while still only returning the share sections for a nucleus the actor can open. + const [generalResult, coordinatorsResult, leadershipsResult] = await Promise.all([ + payload.find({ + collection: 'campaignUser', + where: { role: { equals: 'geral' } }, + depth: 0, + pagination: false, + sort: 'name', + select: { name: true, phone: true }, + overrideAccess: true, + }), + coordinatorIds.length === 0 + ? Promise.resolve({ docs: emptyCampaignUserDocs }) + : payload.find({ + collection: 'campaignUser', + where: { + and: [{ id: { in: coordinatorIds } }, { role: { equals: 'coordenador' } }], + }, + depth: 0, + pagination: false, + sort: 'name', + select: { name: true, phone: true }, + overrideAccess: true, + }), + user.role === 'lideranca' + ? Promise.resolve({ docs: emptyLeadershipDocs }) + : payload.find({ + collection: 'leadership', + where: { + and: [ + { nucleus: { equals: nucleus.id } }, + { supportStatus: { equals: 'engajado' } }, + ], + }, + depth: 1, + pagination: false, + sort: 'id', + select: { contact: true }, + overrideAccess: true, + }), + ]) + + return { + general: generalResult.docs + .map(toCampaignUserRecipient) + .filter((recipient): recipient is NucleusShareRecipient => recipient !== null), + coordinators: coordinatorsResult.docs + .map(toCampaignUserRecipient) + .filter((recipient): recipient is NucleusShareRecipient => recipient !== null), + leaderships: leadershipsResult.docs + .map(toLeadershipRecipient) + .filter((recipient): recipient is NucleusShareRecipient => recipient !== null), + } +} diff --git a/tests/int/campaignNucleusShareRecipients.int.spec.ts b/tests/int/campaignNucleusShareRecipients.int.spec.ts new file mode 100644 index 00000000..896405c4 --- /dev/null +++ b/tests/int/campaignNucleusShareRecipients.int.spec.ts @@ -0,0 +1,154 @@ +// @vitest-environment node + +import { beforeAll, describe, expect, it } from 'vitest' +import { getPayload, type Payload } from 'payload' + +import config from '@/payload.config' +import { getNucleusShareRecipients } from '@/utilities/nucleusShareRecipients' + +import { withCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload + +describe('getNucleusShareRecipients', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('returns geral, nucleus coordinators, and engaged leaderships for staff', async () => { + await withCampaignFixtures(payload, async (fixtures) => { + const generalPhone = fixtures.phone() + const coordinatorPhone = fixtures.phone() + const engagedPhone = fixtures.phone() + const general = await fixtures.createCampaignUser('geral', { phone: generalPhone }) + const coordinator = await fixtures.createCampaignUser('coordenador', { + phone: coordinatorPhone, + }) + const geralAsCoordinator = await fixtures.createCampaignUser('geral', { + phone: fixtures.phone(), + }) + const nucleus = await fixtures.createNucleus({ + coordinators: [coordinator.id, geralAsCoordinator.id], + }) + const engagedContact = await fixtures.createContact({ + name: fixtures.value('Liderança engajada'), + phone: engagedPhone, + }) + const engaged = await fixtures.createLeadership({ + contact: engagedContact, + nucleus: nucleus.id, + supportStatus: 'engajado', + createdBy: general, + }) + const pendingContact = await fixtures.createContact({ + name: fixtures.value('Liderança pendente'), + phone: fixtures.phone(), + }) + await fixtures.createLeadership({ + contact: pendingContact, + nucleus: nucleus.id, + supportStatus: 'a_abordar', + createdBy: general, + }) + + const recipients = await getNucleusShareRecipients(payload, general, nucleus.slug) + + expect(recipients.general.map((item) => item.id)).toEqual( + expect.arrayContaining([general.id, geralAsCoordinator.id]), + ) + expect(recipients.general.every((item) => Boolean(item.phone))).toBe(true) + expect(recipients.coordinators).toEqual([ + { id: coordinator.id, name: coordinator.name, phone: coordinatorPhone }, + ]) + expect(recipients.leaderships).toEqual([ + { id: engaged.id, name: engagedContact.name, phone: engagedPhone }, + ]) + }) + }) + + it('hides leadership recipients for lideranca and still exposes geral phones', async () => { + await withCampaignFixtures(payload, async (fixtures) => { + const generalPhone = fixtures.phone() + const coordinatorPhone = fixtures.phone() + const general = await fixtures.createCampaignUser('geral', { phone: generalPhone }) + const coordinator = await fixtures.createCampaignUser('coordenador', { + phone: coordinatorPhone, + }) + const leader = await fixtures.createCampaignUser('lideranca', { + phone: fixtures.phone(), + }) + const nucleus = await fixtures.createNucleus({ coordinators: [coordinator.id] }) + const contact = await fixtures.createContact({ + name: fixtures.value('Contato líder'), + phone: fixtures.phone(), + }) + await fixtures.createLeadership({ + contact, + nucleus: nucleus.id, + user: leader, + supportStatus: 'engajado', + createdBy: general, + }) + await fixtures.createLeadership({ + contact: await fixtures.createContact({ + name: fixtures.value('Outra liderança'), + phone: fixtures.phone(), + }), + nucleus: nucleus.id, + supportStatus: 'engajado', + createdBy: general, + }) + + const recipients = await getNucleusShareRecipients(payload, leader, nucleus.slug) + + expect( + recipients.general.some((item) => item.id === general.id && item.phone === generalPhone), + ).toBe(true) + expect(recipients.coordinators).toEqual([ + { id: coordinator.id, name: coordinator.name, phone: coordinatorPhone }, + ]) + expect(recipients.leaderships).toEqual([]) + }) + }) + + it('omits recipients without a readable phone', async () => { + await withCampaignFixtures(payload, async (fixtures) => { + const generalWithPhone = await fixtures.createCampaignUser('geral', { + phone: fixtures.phone(), + }) + const generalWithoutPhone = await fixtures.createCampaignUser('geral') + const coordinatorWithoutPhone = await fixtures.createCampaignUser('coordenador') + const nucleus = await fixtures.createNucleus({ + coordinators: [coordinatorWithoutPhone.id], + }) + + const recipients = await getNucleusShareRecipients( + payload, + generalWithPhone, + nucleus.slug, + ) + + expect(recipients.general.map((item) => item.id)).toContain(generalWithPhone.id) + expect(recipients.general.map((item) => item.id)).not.toContain(generalWithoutPhone.id) + expect(recipients.coordinators).toEqual([]) + }) + }) + + it('rejects inaccessible nuclei', async () => { + await withCampaignFixtures(payload, async (fixtures) => { + const foreignCoordinator = await fixtures.createCampaignUser('coordenador', { + phone: fixtures.phone(), + }) + const outsider = await fixtures.createCampaignUser('coordenador', { + phone: fixtures.phone(), + }) + const nucleus = await fixtures.createNucleus({ + coordinators: [foreignCoordinator.id], + }) + + await expect(getNucleusShareRecipients(payload, outsider, nucleus.slug)).rejects.toThrow( + 'Núcleo não encontrado ou sem acesso.', + ) + }) + }) +}) diff --git a/tests/int/campaignUserPhone.int.spec.ts b/tests/int/campaignUserPhone.int.spec.ts index aad8a068..ecea8135 100644 --- a/tests/int/campaignUserPhone.int.spec.ts +++ b/tests/int/campaignUserPhone.int.spec.ts @@ -175,6 +175,33 @@ describe('campaign user contact phone', () => { }) }) + it('lets coordenador and lideranca read contact phones of geral users', async () => { + await withCampaignFixtures(payload, async (fixtures) => { + const generalPhone = fixtures.phone() + const general = await fixtures.createCampaignUser('geral', { phone: generalPhone }) + const coordinator = await fixtures.createCampaignUser('coordenador', { + phone: fixtures.phone(), + }) + const leader = await fixtures.createCampaignUser('lideranca', { + phone: fixtures.phone(), + }) + const nucleus = await fixtures.createNucleus({ coordinators: [coordinator.id] }) + await createEngagedPhoneAccessGraph(fixtures, general, leader, nucleus.id) + + for (const viewer of [coordinator, leader]) { + const visible = await payload.findByID({ + collection: 'campaignUser', + id: general.id, + depth: 0, + select: { name: true, phone: true }, + user: viewer, + overrideAccess: false, + }) + expect(visible.phone).toBe(generalPhone) + } + }) + }) + it('allows owner and geral field updates but protects create from ordinary users', async () => { await withCampaignFixtures(payload, async (fixtures) => { const owner = await fixtures.createCampaignUser('lideranca', { diff --git a/tests/unit/campaignNucleusShare.unit.spec.ts b/tests/unit/campaignNucleusShare.unit.spec.ts new file mode 100644 index 00000000..55ab6a80 --- /dev/null +++ b/tests/unit/campaignNucleusShare.unit.spec.ts @@ -0,0 +1,116 @@ +import { createElement } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ShareNucleusDialog, buildNucleusShareMessage } from '@/components/campaign/ShareNucleusDialog' +import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell' +import type { NucleusShareRecipients } from '@/utilities/nucleusShareRecipients' + +class TestResizeObserver { + observe = () => undefined + unobserve = () => undefined + disconnect = () => undefined +} + +globalThis.ResizeObserver ??= TestResizeObserver + +const recipientsFixture = (): NucleusShareRecipients => ({ + general: [{ id: 1, name: 'Carlos Mendes', phone: '71999990001' }], + coordinators: [{ id: 2, name: 'João Santos', phone: '71999990002' }], + leaderships: [{ id: 3, name: 'Dona Rosa Ferreira', phone: '71999990003' }], +}) + +describe('campaign nucleus share interactions', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('builds the fixed WhatsApp message template', () => { + expect( + buildNucleusShareMessage({ + recipientName: 'Carlos Mendes', + senderName: 'Ana', + nucleusName: 'Quilombo Rio das Rãs', + nucleusUrl: 'https://example.com/campanha/nucleos/quilombo-rio-das-ras', + }), + ).toBe( + 'Oi Carlos Mendes, aqui é Ana da campanha do Solla. Veja o núcleo Quilombo Rio das Rãs: https://example.com/campanha/nucleos/quilombo-rio-das-ras', + ) + }) + + it('loads recipients on open, copies the nucleus link, and opens WhatsApp', async () => { + const nucleusUrl = 'https://example.com/campanha/nucleos/quilombo-rio-das-ras' + const loadRecipients = vi.fn(async () => ({ + recipients: recipientsFixture(), + nucleusUrl, + })) + const writeText = vi.fn(async () => undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const open = vi.spyOn(window, 'open').mockReturnValue(null) + + const ShareDialogWithProps = ( + props: Parameters[0], + ) => createElement(ShareNucleusDialog, props) + + render( + createElement(ShareNucleusDialogShell, { + loadDialogModule: async () => ({ default: ShareDialogWithProps }), + loadRecipients, + nucleusName: 'Quilombo Rio das Rãs', + senderName: 'Ana Ribeiro', + }), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Compartilhar' })) + expect(await screen.findByText('Compartilhar núcleo')).toBeTruthy() + await waitFor(() => expect(loadRecipients).toHaveBeenCalledTimes(1)) + expect(await screen.findByText('Carlos Mendes')).toBeTruthy() + expect(screen.getByText('João Santos')).toBeTruthy() + expect(screen.getByText('Dona Rosa Ferreira')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Copiar' })) + await waitFor(() => expect(writeText).toHaveBeenCalledWith(nucleusUrl)) + expect(await screen.findByRole('button', { name: 'Copiado' })).toBeTruthy() + + fireEvent.click(screen.getAllByRole('button', { name: 'Abrir' })[0]!) + expect(open).toHaveBeenCalledWith( + expect.stringContaining('https://wa.me/5571999990001?text='), + '_blank', + 'noopener,noreferrer', + ) + const openedUrl = new URL(String(open.mock.calls[0]?.[0])) + const message = openedUrl.searchParams.get('text') ?? '' + expect(message).toContain('Oi Carlos Mendes, aqui é Ana Ribeiro') + expect(message).toContain(nucleusUrl) + }) + + it('hides empty recipient sections', async () => { + const loadRecipients = vi.fn(async () => ({ + recipients: { + general: [], + coordinators: [], + leaderships: [], + }, + nucleusUrl: 'https://example.com/campanha/nucleos/nucleo-vazio', + })) + + render( + createElement(ShareNucleusDialogShell, { + loadDialogModule: async () => ({ default: ShareNucleusDialog }), + loadRecipients, + nucleusName: 'Núcleo Vazio', + senderName: 'Coord', + }), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Compartilhar' })) + await screen.findByText('Copiar link') + expect(screen.queryByText('Com coordenação geral')).toBeNull() + expect(screen.queryByText('Com coordenador')).toBeNull() + expect(screen.queryByText('Com liderança')).toBeNull() + }) +})