Add nucleus detail share dialog for WhatsApp and copy-link.
Staff can open recipients for gerais, coordinators, and engaged leaderships without granting access, and coordenadores/lideranças can read geral phones for contact.
This commit is contained in:
parent
5bfa226a7e
commit
b483e0cb32
9 changed files with 843 additions and 1 deletions
|
|
@ -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
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ShareNucleusDialogShell
|
||||
loadRecipients={loadNucleusShareRecipients.bind(null, view.slug)}
|
||||
nucleusName={view.name}
|
||||
senderName={user.name}
|
||||
/>
|
||||
{canEdit ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={`/campanha/nucleos/${view.slug}/editar`}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { headers } from 'next/headers'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
||||
import {
|
||||
getNucleusShareRecipients,
|
||||
type NucleusShareRecipients,
|
||||
} from '@/utilities/nucleusShareRecipients'
|
||||
|
||||
export type NucleusShareRecipientsResult = {
|
||||
recipients: NucleusShareRecipients
|
||||
nucleusUrl: string
|
||||
}
|
||||
|
||||
export const loadNucleusShareRecipients = async (
|
||||
slug: string,
|
||||
): Promise<NucleusShareRecipientsResult> => {
|
||||
const [payload, user, requestHeaders] = await Promise.all([
|
||||
getPayload({ config }),
|
||||
getCampaignUser(),
|
||||
headers(),
|
||||
])
|
||||
if (!user) throw new Error('Faça login para compartilhar este núcleo.')
|
||||
|
||||
const recipients = await getNucleusShareRecipients(payload, user, slug)
|
||||
const baseURL = getCampaignInviteBaseURL({
|
||||
requestOrigin: requestHeaders.get('origin'),
|
||||
forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'),
|
||||
forwardedProto: requestHeaders.get('x-forwarded-proto'),
|
||||
})
|
||||
|
||||
return {
|
||||
recipients,
|
||||
nucleusUrl: `${baseURL}/campanha/nucleos/${slug}`,
|
||||
}
|
||||
}
|
||||
226
src/components/campaign/ShareNucleusDialog.tsx
Normal file
226
src/components/campaign/ShareNucleusDialog.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckIcon, CopyIcon, LinkIcon, MessageCircleIcon } from 'lucide-react'
|
||||
|
||||
import type { NucleusShareRecipientsResult } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import type { NucleusShareRecipient } from '@/utilities/nucleusShareRecipients'
|
||||
import { buildWhatsAppUrl } from '@/utilities/phone'
|
||||
|
||||
export const buildNucleusShareMessage = ({
|
||||
recipientName,
|
||||
senderName,
|
||||
nucleusName,
|
||||
nucleusUrl,
|
||||
}: {
|
||||
recipientName: string
|
||||
senderName: string
|
||||
nucleusName: string
|
||||
nucleusUrl: string
|
||||
}): string =>
|
||||
`Oi ${recipientName}, aqui é ${senderName} da campanha do Solla. Veja o núcleo ${nucleusName}: ${nucleusUrl}`
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0])
|
||||
.join('')
|
||||
.toLocaleUpperCase('pt-BR')
|
||||
}
|
||||
|
||||
const RecipientRow = ({
|
||||
recipient,
|
||||
roleLabel,
|
||||
onOpenWhatsApp,
|
||||
}: {
|
||||
recipient: NucleusShareRecipient
|
||||
roleLabel: string
|
||||
onOpenWhatsApp: (recipient: NucleusShareRecipient) => void
|
||||
}) => (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>{getInitials(recipient.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{recipient.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{roleLabel}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-9 shrink-0 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
onClick={() => onOpenWhatsApp(recipient)}
|
||||
>
|
||||
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const RecipientSection = ({
|
||||
title,
|
||||
roleLabel,
|
||||
recipients,
|
||||
onOpenWhatsApp,
|
||||
}: {
|
||||
title: string
|
||||
roleLabel: string
|
||||
recipients: NucleusShareRecipient[]
|
||||
onOpenWhatsApp: (recipient: NucleusShareRecipient) => void
|
||||
}) => {
|
||||
if (recipients.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-1">
|
||||
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="divide-y divide-border">
|
||||
{recipients.map((recipient) => (
|
||||
<RecipientRow
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
roleLabel={roleLabel}
|
||||
onOpenWhatsApp={onOpenWhatsApp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Compartilhar núcleo</DialogTitle>
|
||||
<DialogDescription>
|
||||
{nucleusName} · envia apenas o link, não concede acesso
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-24 items-center justify-center"
|
||||
aria-label="Carregando destinatários"
|
||||
>
|
||||
<Spinner aria-hidden="true" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Não foi possível carregar</AlertTitle>
|
||||
<AlertDescription>
|
||||
Não foi possível listar os destinatários. Tente novamente.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{copyError ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Cópia indisponível</AlertTitle>
|
||||
<AlertDescription>{copyError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{data && !loading ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<RecipientSection
|
||||
title="Com coordenação geral"
|
||||
roleLabel="Coord. Geral"
|
||||
recipients={data.recipients.general}
|
||||
onOpenWhatsApp={openWhatsApp}
|
||||
/>
|
||||
<RecipientSection
|
||||
title="Com coordenador"
|
||||
roleLabel="Coord. Núcleo"
|
||||
recipients={data.recipients.coordinators}
|
||||
onOpenWhatsApp={openWhatsApp}
|
||||
/>
|
||||
<RecipientSection
|
||||
title="Com liderança"
|
||||
roleLabel="Liderança · Engajada"
|
||||
recipients={data.recipients.leaderships}
|
||||
onOpenWhatsApp={openWhatsApp}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Copiar link
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<LinkIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{data.nucleusUrl}</span>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" className="min-h-11 shrink-0" onClick={copyLink}>
|
||||
{copied ? (
|
||||
<CheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<CopyIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
{copied ? 'Copiado' : 'Copiar'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
127
src/components/campaign/ShareNucleusDialogShell.tsx
Normal file
127
src/components/campaign/ShareNucleusDialogShell.tsx
Normal file
|
|
@ -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<ShareNucleusDialogModule> =>
|
||||
import('./ShareNucleusDialog').then((module) => ({
|
||||
default: module.ShareNucleusDialog,
|
||||
}))
|
||||
|
||||
export type LoadNucleusShareRecipientsAction = () => Promise<NucleusShareRecipientsResult>
|
||||
|
||||
export const ShareNucleusDialogShell = ({
|
||||
loadDialogModule = loadShareNucleusDialog,
|
||||
loadRecipients,
|
||||
nucleusName,
|
||||
senderName,
|
||||
}: {
|
||||
loadDialogModule?: () => Promise<ShareNucleusDialogModule>
|
||||
loadRecipients: LoadNucleusShareRecipientsAction
|
||||
nucleusName: string
|
||||
senderName: string
|
||||
}) => {
|
||||
const [activated, setActivated] = useState(false)
|
||||
const [data, setData] = useState<NucleusShareRecipientsResult | null>(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 (
|
||||
<Dialog
|
||||
open={activated}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
openDialog()
|
||||
return
|
||||
}
|
||||
closeDialog()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" className="min-h-11">
|
||||
<Share2Icon data-icon="inline-start" aria-hidden="true" />
|
||||
Compartilhar
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{activated ? (
|
||||
DialogComponent ? (
|
||||
<DialogComponent
|
||||
data={data}
|
||||
loadError={loadError}
|
||||
loading={loading || status === 'loading'}
|
||||
nucleusName={nucleusName}
|
||||
onRetry={() => {
|
||||
load()
|
||||
void fetchRecipients()
|
||||
}}
|
||||
senderName={senderName}
|
||||
/>
|
||||
) : (
|
||||
<LazyDialogModuleFallback
|
||||
description={`${nucleusName} · envia apenas o link, não concede acesso`}
|
||||
loadingLabel="Carregando compartilhamento"
|
||||
onRetry={load}
|
||||
status={status === 'ready' ? 'loading' : status}
|
||||
title="Compartilhar núcleo"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
123
src/utilities/nucleusShareRecipients.ts
Normal file
123
src/utilities/nucleusShareRecipients.ts
Normal file
|
|
@ -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<Contact>(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<NucleusShareRecipients> => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
154
tests/int/campaignNucleusShareRecipients.int.spec.ts
Normal file
154
tests/int/campaignNucleusShareRecipients.int.spec.ts
Normal file
|
|
@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
116
tests/unit/campaignNucleusShare.unit.spec.ts
Normal file
116
tests/unit/campaignNucleusShare.unit.spec.ts
Normal file
|
|
@ -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<typeof ShareNucleusDialog>[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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue