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.
256 lines
8 KiB
TypeScript
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)
|
|
}
|