src/features/reviews/command-schema.ts
Linked repository file from this build.
import { documentCategories, dropKinds } from "./types";
import { MAX_PDF_BYTES } from "@/lib/documents/storage";
import {
MAX_HOUSEHOLD_SIZE,
MAX_INCOME_ROWS,
MAX_MONEY_CENTS,
isCalendarDate,
isCalendarMonth,
} from "@/programs/snap-negative/input-limits";
import { z } from "zod";
const safeInt = z.int().min(0).max(MAX_MONEY_CENTS);
const optionalDate = z
.string()
.refine((value) => !value || isCalendarDate(value), "Enter a valid calendar date (YYYY-MM-DD).");
const nonEmptyString = z.string().min(1);
const policyDecisionSchema = z.enum(["approve", "deny", "conditional"]);
const scenarioIdSchema = z.enum(["eligibility", "compI", "compII"]);
const workflowActionSchema = z.enum(["submit", "request_changes", "route_pec", "approve"]);
const reviewOutcomeSchema = z.enum(["", "clean", "error", "drop"]);
const suaTypeSchema = z.enum(["heating", "non_heating", "limited", "telephone", "none"]);
const incomeCategorySchema = z.enum(["earned", "unearned", "self_employment"]);
const documentCategorySchema = z.enum(documentCategories);
const householdMemberSchema = z
.object({
id: nonEmptyString,
line: z.string(),
name: z.string(),
relationship: z.string(),
})
.strict();
const incomeStreamSchema = z
.object({
id: nonEmptyString,
memberId: nonEmptyString,
typeCode: z.string(),
category: incomeCategorySchema,
amountCents: safeInt,
expensesCents: safeInt,
selfEmploymentKind: z.enum(["farm", "non_farm"]).optional(),
description: z.string(),
})
.strict();
const scenarioSchema = z
.object({
populated: z.boolean(),
householdSize: z.int().min(0).max(MAX_HOUSEHOLD_SIZE),
income: z.array(incomeStreamSchema).max(MAX_INCOME_ROWS),
educationGrantCents: safeInt.optional(),
educationExpensesCents: safeInt.optional(),
applicationDay: z.int().min(1).max(31).nullable().optional(),
medicalDeductionCents: safeInt,
dependentCareCents: safeInt,
childSupportCents: safeInt,
rentCents: safeInt,
taxesInsuranceCents: safeInt,
installationCents: safeInt,
otherShelterCents: safeInt,
sua: suaTypeSchema,
elderlyDisabled: z.boolean(),
categoricalEligibility: z.boolean(),
homeless: z.boolean(),
})
.strict()
.refine((value) => !value.populated || value.householdSize >= 1, {
path: ["householdSize"],
message: "A populated scenario needs a household size from 1 through 20.",
});
const varianceSchema = z
.object({
element: z.string(),
nature: z.string(),
})
.strict();
const noteKindSchema = z.enum(["ordinary", "pec"]);
const reviewAnswersSchema = z
.object({
telephone: z.string().default(""),
actualAddress: z.string().default(""),
dateAssigned: z.string().default(""),
dateCompleted: z.string().default(""),
dateCleared: z.string().default(""),
stratum: z.string().default(""),
actionType: z.string(),
caseClassification: z.string(),
disposition: z.string(),
nstrReason: z.string(),
finding: z.string(),
recordedReason: z.string(),
recordedReasonAccuracy: z.string(),
noticeRequirement: z.string(),
noticeLanguage: z.string(),
noticeClearUnderstandable: z.string(),
noticeReasonToHh: z.string(),
noticeReasonAccuracy: z.string(),
missedInterviewNotice: z.string(),
contactRequest: z.string(),
verificationRequest: z.string(),
periodicReport: z.string(),
actionTimeliness: z.string(),
daysActionEarlyLate: z.string(),
noticeTimeliness: z.string(),
daysNoticeLate: z.string(),
contactResponse: z.string(),
actionInitiatedBy: z.string(),
narrative: z.string(),
narrativePreset: z.string(),
variances: z.array(varianceSchema).max(1),
processingDistrict1: z.string(),
processingDistrict2: z.string(),
homeCountyArea: z.string(),
homeCountyDigit1: z.string(),
homeCountyDigit2: z.string(),
homeCountyDistrict1: z.string(),
homeCountyDistrict2: z.string(),
fs75: z.string(),
rejectionReasonEsap: z.string(),
workerExperience: z.string(),
timelinessResponse: z.string().optional(),
})
.strict();
const identityPatchSchema = z
.object({
clientName: z.string().optional(),
address: z.string().optional(),
county: z.string().optional(),
district: z.string().optional(),
localAgency: z.string().optional(),
sampleMonth: z
.string()
.refine((value) => !value || isCalendarMonth(value), "Enter a valid sample month (YYYY-MM).")
.optional(),
caseNumber: z.string().optional(),
examiner: z.string().optional(),
supervisor: z.string().optional(),
})
.strict()
.refine((patch) => Object.values(patch).some((value) => value !== undefined), {
message: "At least one identity field is required.",
});
const negativeActionPatchSchema = z
.object({
negativeAction: z.string().optional(),
noticeDate: optionalDate.optional(),
actionDate: optionalDate.optional(),
})
.strict()
.refine((patch) => Object.values(patch).some((value) => value !== undefined), {
message: "At least one negative-action field is required.",
});
const reviewDocumentSchema = z
.object({
id: z.uuid(),
filename: nonEmptyString,
name: z.string().trim().min(1),
category: documentCategorySchema,
description: z.string().trim().min(1),
mimeType: z.literal("application/pdf"),
size: z.int().min(1).max(MAX_PDF_BYTES),
storageKey: z.uuid(),
})
.strict();
const identityCommandSchema = z
.object({
type: z.literal("identity"),
value: identityPatchSchema,
})
.strict();
const householdCommandSchema = z
.object({
type: z.literal("household"),
value: z.array(householdMemberSchema).min(1).max(MAX_HOUSEHOLD_SIZE),
})
.strict();
const negativeActionCommandSchema = z
.object({
type: z.literal("negative_action"),
value: negativeActionPatchSchema,
})
.strict();
const scenarioCommandSchema = z
.object({
type: z.literal("scenario"),
scenario: scenarioIdSchema,
value: scenarioSchema,
})
.strict();
const answersCommandSchema = z
.object({
type: z.literal("answers"),
value: reviewAnswersSchema,
})
.strict();
const outcomeCommandSchema = z
.object({
type: z.literal("outcome"),
value: reviewOutcomeSchema,
})
.strict();
const applyFinalCommandSchema = z
.object({
type: z.literal("apply_final"),
})
.strict();
const noteCommandSchema = z
.object({
type: z.literal("note"),
body: z.string().refine((body) => body.trim().length > 0, {
message: "Note body must not be empty.",
}),
kind: noteKindSchema.optional(),
fieldLink: z.string().max(160).optional(),
})
.strict();
const updateNoteCommandSchema = z
.object({
type: z.literal("update_note"),
noteId: nonEmptyString,
body: z.string().refine((body) => body.trim().length > 0, {
message: "Note body must not be empty.",
}),
})
.strict();
const refuseFindingCommandSchema = z
.object({
type: z.literal("refuse_finding"),
findingId: nonEmptyString,
reason: z.string().refine((reason) => reason.trim().length > 0, {
message: "Refusal reason must not be empty.",
}),
})
.strict();
const policyDecisionCommandSchema = z
.object({
type: z.literal("policy_decision"),
decision: policyDecisionSchema,
policyNote: z.string(),
})
.strict();
const documentCommandSchema = z
.object({
type: z.literal("document"),
document: reviewDocumentSchema,
})
.strict();
const transitionCommandSchema = z
.object({
type: z.literal("transition"),
action: workflowActionSchema,
note: z.string(),
})
.strict();
const computationResultSchema = z
.object({
policyId: z.string(),
householdSize: z.number(),
earnedCents: z.number(),
educationNetCents: z.number().optional(),
earnedAndEducationCents: z.number().optional(),
monthlyBenefitCents: z.number().optional(),
selfEmploymentLossCents: z.number().optional(),
selfEmploymentProfitCents: z.number().optional(),
unearnedCents: z.number(),
grossCents: z.number(),
grossLimitCents: z.number(),
earnedDeductionCents: z.number(),
standardDeductionCents: z.number(),
adjustedIncomeCents: z.number(),
suaCents: z.number(),
shelterCents: z.number(),
shelterDeductionCents: z.number(),
netCents: z.number(),
netLimitCents: z.number(),
allotmentCents: z.number(),
benefitCents: z.number(),
eligible: z.boolean(),
})
.strict();
const factsSchema = z
.object({
negativeAction: z.string(),
noticeDate: optionalDate,
actionDate: optionalDate,
caoIssuedBenefitCents: safeInt.optional(),
paCap: z.enum(["no", "yes", "unknown"]).optional(),
members: z.array(householdMemberSchema).max(MAX_HOUSEHOLD_SIZE),
scenarios: z
.object({ eligibility: scenarioSchema, compI: scenarioSchema, compII: scenarioSchema })
.strict(),
inputsRevision: z.int().min(0),
answers: reviewAnswersSchema.extend({ variances: z.array(varianceSchema) }),
final: z
.object({
inputs: scenarioSchema.optional(),
scenario: z.enum(["compI", "compII"]),
reason: z.string(),
result: computationResultSchema,
errorCents: z.number(),
income: z.array(incomeStreamSchema).max(MAX_INCOME_ROWS),
inputsRevision: z.int().min(-1),
})
.strict()
.optional(),
})
.strict();
export const commandSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("restore_facts"),
facts: factsSchema,
identity: identityPatchSchema,
outcome: reviewOutcomeSchema,
dropKind: z.enum(dropKinds).optional(),
})
.strict(),
z.object({ type: z.literal("issued_benefit"), value: safeInt }).strict(),
z.object({ type: z.literal("drop_kind"), value: z.enum(dropKinds) }).strict(),
z.object({ type: z.literal("approve_memo") }).strict(),
z
.object({
type: z.literal("cao_response"),
status: z.enum(["agree", "dispute", "no_response"]),
rootCause: z.string(),
note: z.string(),
})
.strict(),
z
.object({
type: z.literal("cao_deadline"),
value: z.string().refine(isCalendarDate, "Enter a valid response deadline."),
})
.strict(),
z.object({ type: z.literal("release_summary") }).strict(),
z.object({ type: z.literal("appointment"), value: z.string() }).strict(),
z.object({ type: z.literal("memo"), body: z.string().trim().min(1) }).strict(),
z.object({ type: z.literal("conditional_notes"), body: z.string().trim().min(1) }).strict(),
z.object({ type: z.literal("apply_rules") }).strict(),
z.object({ type: z.literal("reset_demo") }).strict(),
identityCommandSchema,
z.object({ type: z.literal("clear_roster") }).strict(),
householdCommandSchema,
negativeActionCommandSchema,
scenarioCommandSchema,
answersCommandSchema,
outcomeCommandSchema,
z.object({ type: z.literal("prepare_decision") }).strict(),
z
.object({
type: z.literal("update_document"),
documentId: nonEmptyString,
name: z.string().trim().min(1),
description: z.string().trim().min(1),
})
.strict(),
applyFinalCommandSchema,
noteCommandSchema,
updateNoteCommandSchema,
refuseFindingCommandSchema,
policyDecisionCommandSchema,
documentCommandSchema,
transitionCommandSchema,
]);
/** One command as the browser sends it. The server adds the actor and time. */
export const pendingCommandSchema = z
.object({
id: z.uuid(),
command: commandSchema,
})
.strict();
export type ReviewCommand = z.infer<typeof commandSchema>;