teqo/src/lib/engagementLevel.ts
Cursor Agent 9ae062c77c refactor(lib): close the lib→utilities type-import hole + Local API omission guard
- suggestionCatalog type-imports MunicipalityTerritorialClass from
  lib/territorialClassAnchors (the single source) instead of the
  utilities re-export — direction inversion introduced in Pass 3.
- ESLint src/lib zone: @/utilities|components|app restriction now rejects
  type imports too (allowTypeImports: false); shared types live in lib
  contract modules by policy.
- engagementLevel imports DAY_MS from lib/text (fold missed in P3-K).
- searchHomeAdvisors: delete stale 'Intentional admin bypass' comment —
  the call is overrideAccess: false under the staff gate.
- New guard localApiOverrideAccessConventions.unit.spec.ts: payload calls
  with user must declare overrideAccess (baseline measured: 59 calls, 0
  missing), and runtime dynamic import/require of payload/next server
  modules is banned in utilities (dodges the server-only sweep).
2026-07-31 13:17:10 -03:00

173 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* E14 — the N0–N4 engagement ladder and the movement rules that guard it.
* Pure and client-safe: the collection, the server action, the list URL and
* the Popover control all read the ladder from here, and the rules are unit
* tested without a database.
*
* The cuts below are the ones the research report (§6.8) proposes and are
* ILLUSTRATIVE — calibrating them against real movement is E15. They live in
* one object so recalibration is a one-line diff.
*/
import { DAY_MS } from '@/lib/text'
export const engagementLevels = ['n0', 'n1', 'n2', 'n3', 'n4'] as const
/** `allocationDecision.patternId` under which every level movement is filed. */
export const ENGAGEMENT_LEVEL_PATTERN_ID = 'nivel'
/** Cap shared by the field, the schema and the textareas, so they agree. */
export const ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH = 2000
export type EngagementLevel = (typeof engagementLevels)[number]
/**
* Ordinal position on the ladder, for measuring the DISTANCE of a movement —
* ordering is Postgres's job (the column sorts lexicographically in this same
* order). A table rather than `engagementLevels.indexOf` so the lookup is total
* and cannot answer -1.
*/
export const engagementLevelRank: Record<EngagementLevel, number> = {
n0: 0,
n1: 1,
n2: 2,
n3: 3,
n4: 4,
}
export const isEngagementLevel = (value: unknown): value is EngagementLevel =>
typeof value === 'string' && engagementLevels.includes(value as EngagementLevel)
/** The ladder as the mesa says it out loud (research §6.8). */
const engagementLevelNames: Record<EngagementLevel, string> = {
n0: 'Monitorar',
n1: 'Presença de mandato',
n2: 'Rede sem agenda',
n3: 'Rede + agenda',
n4: 'Investimento pleno',
}
/**
* "N3 · Rede + agenda" — the numeral alone is the badge, this is the tooltip.
* Here rather than in `municipalityLabels` so the client island can name a
* level without importing a module that builds the 435-entry catalog and the
* concept glossary at init.
*/
export const formatEngagementLevelLabel = (level: EngagementLevel): string =>
`${level.toUpperCase()} · ${engagementLevelNames[level]}`
export const EMPTY_ENGAGEMENT_LEVEL_LABEL = 'Sem nível'
/**
* Hysteresis, so the ladder records decisions instead of oscillating with the
* mood of the week (research §6.8).
*/
export const ENGAGEMENT_LEVEL_RULES = {
/**
* A freshly decided level is protected from being undone for this long.
* §6.8 words this as protecting a PROMOTION; guarding any recent decision is
* both stricter and derivable from `levelChangedAt` alone — knowing the
* direction of the last movement would cost a query the control cannot make.
* The override covers the difference; E15 calibrates.
*/
protectionWindowDays: 21,
/** Beyond this distance a single movement needs a triangulated shock. */
maxStepsWithoutShock: 1,
} as const
export type EngagementLevelViolationId =
| 'salto-de-dois-niveis'
| 'janela-de-protecao'
| 'dois-movimentos-no-mes'
export type EngagementLevelViolation = {
id: EngagementLevelViolationId
/** Rendered to the coordinator as the reason the movement is being held. */
message: string
}
export type EngagementLevelMovementInput = {
from: EngagementLevel | null
to: EngagementLevel
/** When the current level was recorded; null when the município never had one. */
levelChangedAt: string | null
now: Date
/** The coordinator declared a triangulated shock, which licenses a 2-level jump. */
triangulatedShock: boolean
}
const daysBetween = (from: Date, to: Date): number => (to.getTime() - from.getTime()) / DAY_MS
const isSameCalendarMonth = (left: Date, right: Date): boolean =>
left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth()
/**
* The movement rules, as a list rather than a boolean: the control shows the
* coordinator every reason at once, and the accepted override is stored with
* exactly the reasons it overrode.
*
* "Two movements in a month" needs no history query — the movement being made
* is at least the second one of the month precisely when the previous one
* landed in the same calendar month.
*/
export const getEngagementLevelViolations = ({
from,
to,
levelChangedAt,
now,
triangulatedShock,
}: EngagementLevelMovementInput): EngagementLevelViolation[] => {
// The first level a município ever gets is a starting point, not a movement:
// there is nothing to oscillate against yet.
if (from === null || from === to) return []
const violations: EngagementLevelViolation[] = []
const steps = Math.abs(engagementLevelRank[to] - engagementLevelRank[from])
const isPromotion = engagementLevelRank[to] > engagementLevelRank[from]
if (steps > ENGAGEMENT_LEVEL_RULES.maxStepsWithoutShock && !triangulatedShock) {
violations.push({
id: 'salto-de-dois-niveis',
message: `Movimento de ${steps} níveis de uma vez. Só com choque triangulado.`,
})
}
const changedAt = levelChangedAt ? new Date(levelChangedAt) : null
const hasChangedAt = changedAt !== null && !Number.isNaN(changedAt.getTime())
if (hasChangedAt) {
const daysSinceChange = daysBetween(changedAt, now)
if (!isPromotion && daysSinceChange < ENGAGEMENT_LEVEL_RULES.protectionWindowDays) {
violations.push({
id: 'janela-de-protecao',
message: `O nível atual tem menos de ${ENGAGEMENT_LEVEL_RULES.protectionWindowDays} dias. Rebaixar agora desfaz uma decisão dentro da janela de proteção.`,
})
}
if (isSameCalendarMonth(changedAt, now)) {
violations.push({
id: 'dois-movimentos-no-mes',
message: 'Este município já mudou de nível neste mês.',
})
}
}
return violations
}
/**
* Thrown by the server action when a movement breaks a rule and the
* coordinator has not overridden it. It carries the reasons so the route can
* answer with them and the control can offer the override in place, instead of
* collapsing everything into one opaque "não foi possível salvar".
*/
export class EngagementLevelBlockedError extends Error {
readonly violations: EngagementLevelViolation[]
constructor(violations: EngagementLevelViolation[]) {
super('O movimento contraria as regras de estabilidade do nível.')
this.name = 'EngagementLevelBlockedError'
this.violations = violations
}
}