teqo-backup/src/lib/schemas/campaignDemand.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

118 lines
4.7 KiB
TypeScript

export const campaignDemandKinds = [
'material',
'servico',
'transporte',
'alimentacao',
'infraestrutura',
'espaco',
'equipamento',
'pessoal_apoio',
'outro',
] as const
export type CampaignDemandKind = (typeof campaignDemandKinds)[number]
export const campaignDemandKindLabels: Record<CampaignDemandKind, string> = {
material: 'Material',
servico: 'Serviço',
transporte: 'Transporte',
alimentacao: 'Alimentação',
infraestrutura: 'Infraestrutura',
espaco: 'Espaço',
equipamento: 'Equipamento',
pessoal_apoio: 'Pessoal de apoio',
outro: 'Outro',
}
/**
* Shared demand-form copy (B195 single free-text field): used by the wizard
* step and the `/demandas/nova` form so the two cadastros never diverge.
*/
export const CAMPAIGN_DEMAND_BODY_LABEL = 'O que você precisa?' as const
export const CAMPAIGN_DEMAND_BODY_DESCRIPTION =
'Descreva o que você precisa, com o quê e os detalhes.' as const
export const CAMPAIGN_DEMAND_BODY_PLACEHOLDER =
'Ex.: 500 santinhos para a feira de sábado; transporte para o comício; diária da equipe…' as const
export const CAMPAIGN_DEMAND_BODY_MAX_LENGTH = 4000 as const
export const CAMPAIGN_DEMAND_KIND_LABEL = 'Tipo' as const
export const CAMPAIGN_DEMAND_ACTIVITY_LABEL = 'Atividade relacionada' as const
export const CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION =
'Opcional. Busque atividades do município selecionado.' as const
export const CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL = 'Nenhuma atividade' as const
export const CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION =
'Busque atividades do município selecionado por título.' as const
export const CAMPAIGN_DEMAND_SUBMIT_LABEL = 'Abrir demanda' as const
export const campaignDemandStatuses = [
'aberta',
'em_analise',
'escalada',
'aprovada',
'rejeitada',
] as const
export type CampaignDemandStatus = (typeof campaignDemandStatuses)[number]
export const campaignDemandStatusLabels: Record<CampaignDemandStatus, string> = {
aberta: 'Aberta',
em_analise: 'Em análise',
escalada: 'Escalada ao Coordenador Geral',
aprovada: 'Aprovada',
rejeitada: 'Rejeitada',
}
/** Allowed workflow transitions (staff-driven; decisions on escalated demands are coordinator-only). */
export const campaignDemandTransitions: Record<CampaignDemandStatus, CampaignDemandStatus[]> = {
aberta: ['em_analise', 'escalada', 'aprovada', 'rejeitada'],
em_analise: ['escalada', 'aprovada', 'rejeitada'],
escalada: ['aprovada', 'rejeitada'],
aprovada: [],
rejeitada: [],
}
export const campaignDemandTransitionLabels: Record<CampaignDemandStatus, string> = {
aberta: 'Reabrir',
em_analise: 'Iniciar análise',
escalada: 'Escalar ao Coordenador Geral',
aprovada: 'Aprovar',
rejeitada: 'Rejeitar',
}
/**
* Refusal messages matched by exact string in the routes' `safeMessages`
* (`mapCampaignFormActionError`). Named once per the B32+/B37 contract: a
* reworded literal at either end silently collapses the refusal into the
* generic message — the conventions spec bans literals at both ends.
*/
export const CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE = 'Status de demanda inválido.'
export const CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato movem demandas.'
export const CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE =
'Demandas escaladas são decididas pelo Coordenador Geral ou Candidato.'
export const CAMPAIGN_DEMAND_COST_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato registram custos.'
export const CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato editam demandas.'
export const CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE =
'Somente quem vê a demanda pode gerenciar os responsáveis.'
export const CAMPAIGN_DEMAND_RESPONSIBLES_LABEL = 'Responsáveis pela demanda'
export const CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION =
'Sugestões: assessores do município. Se ninguém for marcado além de você, só você, candidato e coordenador verão a demanda.'
export const CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE =
'Somente a coordenação e a assessoria anexam comprovantes.'
export const CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE = 'Envie uma imagem (JPEG, PNG, WebP) ou PDF.'
export const CAMPAIGN_DEMAND_RECEIPT_EMPTY_MESSAGE = 'O arquivo enviado está vazio.'
export const CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE = 'O comprovante deve ter no máximo 10 MB.'
export const CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES = [
CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE,
CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE,
CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE,
] as const
export const CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES = [
CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_EMPTY_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE,
] as const