teqo/src/app/(campaign)/campanha/actions/advisor.ts
Francisco Ignácio Fontoura Solla 812e3a0bee B34: editable município chips on the leaderships list.
The "Municípios" column was dead text; it is now chips that navigate to the
município, with a territory chip when a leadership covers a whole TI, and it is
editable in place. What picks inline vs. Drawer is the `pointer:` media query,
not viewport width: on a fine pointer the editor is always mounted (the "×" sits
at the chip's corner, revealed on hover/focus) and on a coarse pointer a tap
anywhere in the cell opens a Drawer with 44 px targets.

`AdvisorMunicipalityCell` is promoted to `shared/MunicipalityPortfolioCell` and
/campanha/assessores migrates onto it in the same change, so the second call site
is born with the first. One batch action only; the single-id advisor twin is
deleted with its int tests migrated. The anti-goal on always-mounted list inputs
is amended for chip-based relations on fine pointers, not reopened.

Also fixes a latent bug: `CampaignTableHead`'s `description` seam broke any RSC
caller, because its two helpers lived in a 'use client' module.
2026-07-27 22:35:19 -03:00

256 lines
8 KiB
TypeScript

'use server'
import { randomBytes } from 'node:crypto'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import {
ADVISOR_EMAIL_CONFLICT_MESSAGE,
ADVISOR_UNRESTRICTED_MESSAGE,
PLACEHOLDER_RESET_MESSAGE,
advisorCreateSchema,
advisorMunicipalitiesBatchSchema,
advisorPasswordResetSchema,
advisorProfileUpdateSchema,
isPlanilhaPlaceholderEmail,
type AdvisorCreateInput,
type AdvisorMunicipalitiesBatchInput,
type AdvisorPasswordResetInput,
type AdvisorProfileUpdateInput,
} from '@/lib/schemas/advisor'
import type { CampaignUser } from '@/payload-types'
import {
getCampaignActionContext,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import {
assertCampaignEmailConfigured,
isCampaignEmailConfigured,
} from '@/utilities/campaignPasswordReset'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { uniqueRelationshipIds } from '@/utilities/relationship'
const revalidateAdvisorPaths = (advisorId?: number) => {
revalidatePath('/campanha/assessores', 'page')
if (advisorId !== undefined) {
revalidatePath(`/campanha/assessores/${advisorId}`, 'page')
}
}
type AdvisorAccount = {
id: number
name: string
email?: string | null
phone?: string | null
role: CampaignUser['role']
}
const assertTargetAdvisor = async (
payload: Payload,
advisorId: number,
actorId: number,
): Promise<AdvisorAccount> => {
const target = await payload.findByID({
collection: 'campaignUser',
id: advisorId,
depth: 0,
select: { name: true, email: true, phone: true, role: true },
overrideAccess: true,
})
if (target.role !== 'advisor') {
throw new Error('Só é possível gerenciar contas com papel de Assessor nesta tela.')
}
if (String(target.id) === String(actorId)) {
throw new Error('Use Meu perfil para alterar a própria conta.')
}
return target
}
const translateUniqueEmailConflict = (error: unknown): never => {
const message = error instanceof Error ? error.message : String(error)
// Payload may surface the unique email collision as email, username, or a
// generic "field is invalid" ValidationError depending on auth config.
if (/email|username|duplicate key|unique/i.test(message)) {
throw new Error(ADVISOR_EMAIL_CONFLICT_MESSAGE)
}
throw error instanceof Error ? error : new Error(String(error))
}
export const createAdvisorRecord = async (
payload: Payload,
actor: CampaignUser,
input: AdvisorCreateInput,
) => {
const data = advisorCreateSchema.parse(input)
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
try {
const created = await payload.create({
collection: 'campaignUser',
data: hookFilledCreateData<'campaignUser'>({
name: data.name,
email: data.email,
role: 'advisor',
password: randomBytes(24).toString('base64url'),
...(data.phone !== undefined ? { phone: data.phone } : {}),
}),
depth: 0,
user: currentActor,
overrideAccess: false,
})
return created
} catch (error) {
return translateUniqueEmailConflict(error)
}
}
export const createAdvisor = async (input: AdvisorCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
const created = await createAdvisorRecord(payload, actor, input)
revalidateAdvisorPaths()
return created
}
export const updateAdvisorProfileRecord = async (
payload: Payload,
actor: CampaignUser,
input: AdvisorProfileUpdateInput,
) => {
const { id, ...fields } = advisorProfileUpdateSchema.parse(input)
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
await assertTargetAdvisor(payload, id, currentActor.id)
const data: {
name?: string
email?: string
phone?: string | null
} = {}
if (fields.name !== undefined) data.name = fields.name
if (fields.email !== undefined) data.email = fields.email
if (fields.phone !== undefined) data.phone = fields.phone
try {
const updated = await payload.update({
collection: 'campaignUser',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
})
return updated
} catch (error) {
return translateUniqueEmailConflict(error)
}
}
export const updateAdvisorProfile = async (input: AdvisorProfileUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
const updated = await updateAdvisorProfileRecord(payload, actor, input)
revalidateAdvisorPaths(updated.id)
return updated
}
export const setAdvisorMunicipalitiesBatchRecord = async (
payload: Payload,
actor: CampaignUser,
input: AdvisorMunicipalitiesBatchInput,
) => {
const { advisorId, municipalityIds, assigned } = advisorMunicipalitiesBatchSchema.parse(input)
const uniqueMunicipalityIds = [...new Set(municipalityIds)].sort((left, right) => left - right)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadUnrestrictedActor(
payload,
actor,
ADVISOR_UNRESTRICTED_MESSAGE,
req,
)
await assertTargetAdvisor(payload, advisorId, currentActor.id)
await acquireTextAdvisoryLocks(
payload,
req,
uniqueMunicipalityIds.map((id) => `municipality-advisors:${id}`),
)
let lastSlug: string | undefined
for (const municipalityId of uniqueMunicipalityIds) {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { advisors: true, slug: true },
overrideAccess: true,
req,
})
const currentAdvisorIDs = uniqueRelationshipIds(municipality.advisors)
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
if (nextAdvisorIDs === null) continue
// Intentional admin bypass: unrestricted role verified above; advisors
// field update access is admin-only in the collection config.
await payload.update({
collection: 'municipality',
id: municipalityId,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
if (typeof municipality.slug === 'string') lastSlug = municipality.slug
}
return { slug: lastSlug }
},
{ beginFailureMessage: 'Não foi possível atualizar a carteira do assessor.' },
)
}
export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalitiesBatchInput) => {
const { payload, actor } = await getCampaignActionContext()
const result = await setAdvisorMunicipalitiesBatchRecord(payload, actor, input)
revalidateMunicipalityListPaths({ slug: result.slug })
revalidateAdvisorPaths(input.advisorId)
return result
}
export const sendAdvisorPasswordResetRecord = async (
payload: Payload,
actor: CampaignUser,
input: AdvisorPasswordResetInput,
) => {
const { advisorId } = advisorPasswordResetSchema.parse(input)
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
const target = await assertTargetAdvisor(payload, advisorId, currentActor.id)
if (!target.email || isPlanilhaPlaceholderEmail(target.email)) {
throw new Error(PLACEHOLDER_RESET_MESSAGE)
}
assertCampaignEmailConfigured()
await payload.forgotPassword({
collection: 'campaignUser',
data: { email: target.email },
disableEmail: !isCampaignEmailConfigured(),
})
return { email: target.email }
}
export const sendAdvisorPasswordReset = async (input: AdvisorPasswordResetInput) => {
const { payload, actor } = await getCampaignActionContext()
return sendAdvisorPasswordResetRecord(payload, actor, input)
}