export const ticketPattern = /^Ticket#\d{6}$/; export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/; export const monthPattern = /^\d{4}-\d{2}$/; export const dayPattern = /^\d{4}-\d{2}-\d{2}$/; export const usernamePattern = /^\S{3,40}$/u; export function requireString(value: unknown, field: string) { if (typeof value !== "string" || value.trim().length === 0) { throw badRequest(`${field} is required`); } return value.trim(); } export function parseTicketNumber(value: unknown) { const ticketNumber = requireString(value, "ticketNumber"); if (!ticketPattern.test(ticketNumber)) { throw badRequest("ticketNumber must match Ticket#XXXXXX"); } return ticketNumber; } export function parseTrackableTicketNumber(value: unknown) { const ticketNumber = requireString(value, "ticketNumber"); if (!trackableTicketPattern.test(ticketNumber)) { throw badRequest("ticketNumber must match Ticket#XXXXXX or Fix#ID"); } return ticketNumber; } export function parseUsername(value: unknown) { const username = requireString(value, "username"); if (!usernamePattern.test(username)) { throw badRequest("username must be 3-40 characters without spaces"); } return username; } export function parseMonth(value: string) { if (!monthPattern.test(value)) { throw badRequest("month must use YYYY-MM"); } const monthStart = `${value}-01`; const date = new Date(`${monthStart}T00:00:00.000Z`); if (Number.isNaN(date.getTime()) || date.getUTCMonth() + 1 !== Number(value.slice(5, 7))) { throw badRequest("month is invalid"); } const next = new Date(date); next.setUTCMonth(next.getUTCMonth() + 1); return { label: value, start: monthStart, startIso: date.toISOString(), endIso: next.toISOString() }; } export function parseDay(value: string) { if (!dayPattern.test(value)) { throw badRequest("day must use YYYY-MM-DD"); } const date = new Date(`${value}T00:00:00.000Z`); if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) { throw badRequest("day is invalid"); } const next = new Date(date); next.setUTCDate(next.getUTCDate() + 1); return { label: value, start: value, startIso: date.toISOString(), endIso: next.toISOString() }; } export function parsePositiveInteger(value: unknown, field: string) { const number = Number(value); if (!Number.isInteger(number) || number < 0) { throw badRequest(`${field} must be a positive integer`); } return number; } export function parseWorkType(value: unknown) { if (value !== "support" && value !== "consulting") { throw badRequest("workType must be support or consulting"); } return value; } export function parseBillingStatus(value: unknown) { if (value === null) { return null; } if (value !== "billable" && value !== "billed" && value !== "non_billable") { throw badRequest("billingStatus must be billable, billed, non_billable or null"); } return value; } export function parseIsoDate(value: unknown, field: string) { const raw = requireString(value, field); const date = new Date(raw); if (Number.isNaN(date.getTime())) { throw badRequest(`${field} must be an ISO date`); } return date; } export function badRequest(message: string) { const error = new Error(message) as Error & { status?: number }; error.status = 400; return error; }