teqo/src/utilities/campaignDemandData.ts
Francisco Solla 2cc8f2c14d feat(C143): demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
- campaignDemand.responsibles (hasMany → campaignUser, staff elegível) com
  hook beforeValidate que descarta ids não-staff (fail-closed)
- canRead/UpdateCampaignDemand por membership: assessor só vê demandas onde
  é responsável; unrestricted/admin sempre; leader nunca (fim do escopo
  municipal de demandas)
- criador entra automaticamente como responsável no hook de criação (form,
  wizard, rascunhos C90); migration com backfill created_by_id → responsável
- UI: picker multi-select na criação (nova + wizard) e card de gestão no
  detalhe com save full-replace + advisory lock; sugestões = assessores do
  município; rótulo 'Visível para: Responsáveis · Candidato · Coordenador'
- int tests reescritos para a regra nova + 5 casos novos; e2e novo de
  visibilidade (lista/URL/gestão); fixture e2e com createCampaignDemand
- impl plan + changelog; importMap regenerado com S3_* dummy (OPS69)
2026-08-19 23:55:07 -03:00

233 lines
7 KiB
TypeScript

import 'server-only'
import type { Payload } from 'payload'
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import type { Activity, CampaignDemand, CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildDemandListHref,
buildDemandListWhere,
parseDemandListParams,
type DemandListState,
} from '@/utilities/demand/demandListUrl'
import {
loadCampaignUserDisplayByIds,
loadCampaignUserNamesByIds,
loadLeadershipContactNamesByIds,
loadMunicipalityLabelsByIds,
} from '@/utilities/loadNamesByIds'
export { buildDemandListHref, parseDemandListParams }
export type { DemandListState }
const demandPageSize = 25
export type DemandRowViewModel = {
id: number
title: string
slug: string
kind: CampaignDemandKind
status: CampaignDemandStatus
municipalityId: number
municipalityName: string
municipalitySlug: string
requesterName: string | null
createdAt: string
}
const resolveMunicipalityAndRequesterNames = async (
payload: Payload,
demands: CampaignDemand[],
): Promise<{
municipalityBy: Map<number, { name: string; slug: string }>
requesterBy: Map<number, string>
}> => {
const municipalityIDs = [
...new Set(
demands
.map((demand) => relationshipId(demand.municipality))
.filter((id): id is number => id !== null),
),
]
const leadershipIDs = [
...new Set(
demands
.map((demand) => relationshipId(demand.leadership))
.filter((id): id is number => id !== null),
),
]
const [municipalityBy, contactNameByLeadershipId] = await Promise.all([
loadMunicipalityLabelsByIds(payload, municipalityIDs),
loadLeadershipContactNamesByIds(payload, leadershipIDs),
])
return {
municipalityBy,
requesterBy: contactNameByLeadershipId,
}
}
const toDemandRow = (
demand: CampaignDemand,
municipalityBy: Map<number, { name: string; slug: string }>,
requesterBy: Map<number, string>,
): DemandRowViewModel => {
const municipality = municipalityBy.get(relationshipId(demand.municipality) ?? -1)
const municipalityID = relationshipId(demand.municipality)
const leadershipID = relationshipId(demand.leadership)
return {
id: demand.id,
title: demand.title,
slug: demand.slug,
kind: demand.kind as CampaignDemandKind,
status: demand.status as CampaignDemandStatus,
municipalityId: municipalityID ?? 0,
municipalityName: municipality?.name ?? 'Município',
municipalitySlug: municipality?.slug ?? '',
requesterName: leadershipID ? (requesterBy.get(leadershipID) ?? null) : null,
createdAt: demand.createdAt,
}
}
export const loadDemandListPageData = async (
payload: Payload,
user: CampaignUser,
state: DemandListState,
): Promise<{
rows: DemandRowViewModel[]
totalDocs: number
totalPages: number
openCount: number
}> => {
const [result, openCount] = await Promise.all([
payload.find({
collection: 'campaignDemand',
where: buildDemandListWhere(state),
depth: 0,
limit: demandPageSize,
page: state.page,
sort: '-createdAt',
user,
overrideAccess: false,
}),
payload.count({
collection: 'campaignDemand',
where: { status: { in: ['aberta', 'em_analise', 'escalada'] } },
user,
overrideAccess: false,
}),
])
const { municipalityBy, requesterBy } = await resolveMunicipalityAndRequesterNames(
payload,
result.docs as CampaignDemand[],
)
return {
rows: (result.docs as CampaignDemand[]).map((demand) =>
toDemandRow(demand, municipalityBy, requesterBy),
),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
openCount: openCount.totalDocs,
}
}
export type DemandDetailViewModel = DemandRowViewModel & {
description: string | null
decisionNote: string | null
decidedAt: string | null
/** Staff-only (field access strips for leaders). */
cost: number | null
receipts: Array<{ id: number; url: string | null; filename: string | null }>
statusHistory: Array<{
status: CampaignDemandStatus
note: string | null
authorName: string | null
createdAt: string | null
}>
/** C143 — explicit responsibles (creator included by the collection hook). */
responsibles: Array<{ id: number; name: string; avatarUrl: string | null }>
/** C143 — the creator id, for the "(criador)" chip marker on the detail. */
createdBy: number | null
canLeaderEdit: boolean
activity: { title: string; slug: string } | null
}
export const loadDemandDetail = async (
payload: Payload,
user: CampaignUser,
slug: string,
): Promise<DemandDetailViewModel | null> => {
const result = await payload.find({
collection: 'campaignDemand',
where: { slug: { equals: slug } },
depth: 1,
limit: 1,
pagination: false,
user,
overrideAccess: false,
})
const demand = result.docs[0] as CampaignDemand | undefined
if (!demand) return null
const { municipalityBy, requesterBy } = await resolveMunicipalityAndRequesterNames(payload, [
demand,
])
const row = toDemandRow(demand, municipalityBy, requesterBy)
const authorIDs = [
...new Set(
(demand.statusHistory ?? [])
.map((entry) => relationshipId(entry.author))
.filter((id): id is number => id !== null),
),
]
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
const responsibleIDs = (demand.responsibles ?? [])
.map((responsible) => relationshipId(responsible))
.filter((id): id is number => id !== null)
const responsibleById = await loadCampaignUserDisplayByIds(payload, responsibleIDs)
return {
...row,
description: demand.description ?? null,
decisionNote: demand.decisionNote ?? null,
decidedAt: demand.decidedAt ?? null,
createdBy: relationshipId(demand.createdBy),
responsibles: responsibleIDs.flatMap((id) => {
const display = responsibleById.get(id)
return display ? [{ id, ...display }] : []
}),
cost: isCampaignStaff(user) ? (demand.cost ?? null) : null,
receipts: isCampaignStaff(user)
? (demand.receipts ?? []).flatMap((receipt) =>
typeof receipt === 'object' && receipt !== null
? [
{
id: receipt.id,
url: receipt.url ?? null,
filename: receipt.filename ?? null,
},
]
: [],
)
: [],
statusHistory: isCampaignStaff(user)
? (demand.statusHistory ?? []).map((entry) => ({
status: entry.status as CampaignDemandStatus,
note: entry.note ?? null,
authorName: authorNameById.get(relationshipId(entry.author) ?? -1) ?? null,
createdAt: entry.createdAt ?? null,
}))
: [],
canLeaderEdit: user.role === 'leader' && demand.status === 'aberta',
activity: isPopulatedRelationship<Activity>(demand.activity)
? { title: demand.activity.title, slug: demand.activity.slug }
: null,
}
}