teqo/src/utilities/notification/notificationEvents.ts
Francisco Solla 212a1f2423 feat(campaign): C87 — atualização unificada (texto + polaridade + urgente)
Substitui o modelo de MunicipalityUpdate (kind/semanal/urgente/nota/sinal + signalType)
por um modelo unificado: body obrigatório + polarity (boa/neutra/ruim) + urgent + adversarySignal.

- Schema: polarity enum obrigatório, urgent boolean default false, adversarySignal boolean
- Wizard: novo WizardUpdateBodyStep com campo de texto + polaridade + urgente
- Lista: controle unificado de atualização com polaridade inline
- Feed: exibe polaridade como badge colorido + indicador de urgência
- V2 status strip: atualização rápida de polaridade
- E11: queries atualizadas para polarity + adversarySignal
- Migração: 20260807_030000_unify_municipality_update (backfill polarity=neutra, urgent de kind, adversarySignal de kind+signalType)
- Testes: 18/18 integração C87, 593/593 total, 1399/1399 unitários
- Fix: testes de state deputy usavam nomes fixos que colidiam entre execuções
2026-08-07 05:04:41 -03:00

257 lines
6.8 KiB
TypeScript

import 'server-only'
/** Domain hooks create notifications with admin bypass (`overrideAccess: true`). */
import type { PayloadRequest } from 'payload'
import {
ENABLE_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
} from '@/lib/notificationContract'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { MunicipalityUpdatePolarity } from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import { createCampaignNotifications } from '@/utilities/notification/createCampaignNotification'
import { resolveMunicipalityStaffRecipientIds } from '@/utilities/notification/notificationRecipients'
export type NotificationEventRequest = Pick<PayloadRequest, 'payload' | 'context' | 'transactionID'>
const shouldSkipCampaignNotification = (context: Record<string, unknown>): boolean => {
if (context[ENABLE_CAMPAIGN_NOTIFICATION_CONTEXT_KEY]) return false
if (context[SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY]) return true
// Int suite shares one Postgres across ~48 workers; hook side-effects (extra
// rows, coordinator fan-out reads) are tested in campaignNotifications.int.
return process.env.VITEST === 'true'
}
export const notifyMunicipalityUpdateCreated = async (
req: NotificationEventRequest,
doc: {
id: number
municipality: unknown
author: unknown
polarity: MunicipalityUpdatePolarity
},
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const municipalityID = relationshipId(doc.municipality)
if (!municipalityID) return
const authorID = relationshipId(doc.author)
const municipality = await req.payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { name: true, slug: true },
overrideAccess: true,
req,
})
let authorName = 'Equipe'
if (authorID) {
const author = await req.payload.findByID({
collection: 'campaignUser',
id: authorID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
authorName = author.name
}
const polarityLabel =
doc.polarity in municipalityUpdatePolarityLabels
? municipalityUpdatePolarityLabels[
doc.polarity as keyof typeof municipalityUpdatePolarityLabels
]
: 'Atualização'
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
req.payload,
municipalityID,
{ excludeUserId: authorID },
req,
)
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'municipality_update',
municipalityID,
payload: {
title: `${polarityLabel} — ${municipality.name}`,
detail: `${authorName} registrou uma atualização`,
href: `/campanha/municipios/${municipality.slug}`,
},
},
req,
)
}
export const notifySupporterCreated = async (
req: NotificationEventRequest,
doc: { municipality: unknown; createdBy?: unknown; contact?: unknown },
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const municipalityID = relationshipId(doc.municipality)
if (!municipalityID) return
const authorID = relationshipId(doc.createdBy)
const [municipality, contactName] = await Promise.all([
req.payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { name: true, slug: true },
overrideAccess: true,
req,
}),
loadSupporterContactName(req, doc),
])
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
req.payload,
municipalityID,
{ excludeUserId: authorID },
req,
)
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'new_supporter',
municipalityID,
payload: {
title: `Novo apoiador — ${municipality.name}`,
detail: contactName ? `${contactName} entrou na base` : 'Novo cadastro na base',
href: '/campanha/apoiadores',
},
},
req,
)
}
const loadSupporterContactName = async (
req: NotificationEventRequest,
doc: { contact?: unknown },
): Promise<string | null> => {
const contactID = relationshipId(doc.contact)
if (!contactID) return null
const contact = await req.payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
return contact.name ?? null
}
export const notifyActivityNeedsAttention = async (
req: NotificationEventRequest,
doc: {
title: string
slug: string
municipality: unknown
advisors?: unknown
},
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const municipalityID = relationshipId(doc.municipality)
if (!municipalityID) return
const municipality = await req.payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
const advisorIDs = uniqueRelationshipIds(doc.advisors as readonly unknown[] | null | undefined)
const recipientIDs = [...new Set(advisorIDs)]
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'activity_attention',
municipalityID,
payload: {
title: `Atividade precisa de atenção — ${doc.title}`,
detail: `Em ${municipality.name}`,
href: `/campanha/atividades/${doc.slug}`,
},
},
req,
)
}
export const notifyInviteAccepted = async (
req: NotificationEventRequest,
leadership: { contact: unknown; municipalities?: unknown },
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const contactID = relationshipId(leadership.contact)
const municipalityIDs = uniqueRelationshipIds(
leadership.municipalities as readonly unknown[] | null | undefined,
)
if (municipalityIDs.length === 0) return
const contact = contactID
? await req.payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
: null
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
req.payload,
municipalityIDs[0],
{ includeUnrestricted: true },
req,
)
if (recipientIDs.length === 0) return
const municipality = await req.payload.findByID({
collection: 'municipality',
id: municipalityIDs[0],
depth: 0,
select: { name: true, slug: true },
overrideAccess: true,
req,
})
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'invite_accepted',
municipalityID: municipalityIDs[0],
payload: {
title: `Convite aceito — ${contact?.name ?? 'Nova liderança'}`,
detail: `Entrou em ${municipality.name}`,
href: '/campanha/liderancas',
},
},
req,
)
}