← back to NationalPaperHangers Yfr2 C3
lib/domain/measure-tier.js
138 lines
'use strict';
const POLICY_VERSION = 'measure_tier_v1';
const ELIGIBLE_STATES = new Set(['routed', 'viewed', 'accepted', 'declined', 'expired']);
const MEASUREMENT_METHODS = new Set(['manual', 'calculator', 'camera', 'installer']);
const CONTACT_PREFERENCES = new Set(['email', 'phone', 'either']);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^\+?[1-9]\d{7,14}$/;
const ZIP_RE = /^\d{5}$/;
const CAPTURE_PHOTO_RE = /^\/uploads\/bookings\/[0-9a-f]{24}\.(jpg|png|webp|avif|heic|heif)$/;
function text(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function positive(value) {
if (typeof value !== 'number' && !(typeof value === 'string' && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))) {
return false;
}
const number = Number(value);
return Number.isFinite(number) && number > 0;
}
function validDate(value) {
if (value instanceof Date) return Number.isFinite(value.getTime());
if (typeof value !== 'string') return false;
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,6})?(Z|([+-])(\d{2}):(\d{2}))$/);
if (!match) return false;
const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone, , zoneHourText, zoneMinuteText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
const daysInMonth = month >= 1 && month <= 12
? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
const validZone = zone === 'Z' || (Number(zoneHourText) <= 23 && Number(zoneMinuteText) <= 59);
return year >= 1 && day >= 1 && day <= daysInMonth && hour <= 23 && minute <= 59 && second <= 59
&& validZone && Number.isFinite(new Date(value).getTime());
}
function positiveIntegerId(value) {
if (typeof value === 'number') return Number.isSafeInteger(value) && value > 0;
return typeof value === 'string' && /^[1-9]\d*$/.test(value);
}
function validEmail(value) {
const normalized = text(value);
return normalized !== null && EMAIL_RE.test(normalized);
}
function validPhone(value) {
const normalized = text(value);
return normalized !== null && PHONE_RE.test(normalized);
}
function validRoomCapture(capture) {
return capture !== null && typeof capture === 'object' && !Array.isArray(capture)
&& typeof capture.photo_url === 'string' && CAPTURE_PHOTO_RE.test(capture.photo_url)
&& positive(capture.wall_width_ft) && positive(capture.wall_height_ft);
}
/**
* Pure, versioned tier predicate. It reads only explicit stored-field values;
* it never parses notes or infers consent, test status, contact, or product data.
*
* @returns {{tier: 'ineligible'|'standard'|'spec_ready', reason_codes: string[], policy_version: string}}
*/
function classifyMeasureTier(job = {}) {
const reasons = [];
const fail = (condition, code) => {
if (!condition) reasons.push(code);
return condition;
};
const production = fail(job.environment === 'production', 'not_production');
const nonTest = fail(job.is_test === false, job.is_test === true ? 'test_record' : 'missing_test_provenance');
const captured = fail(validDate(job.captured_at), 'missing_captured_at');
const consentedAt = fail(validDate(job.consent_at), 'missing_consent_at');
const consentVersion = fail(text(job.consent_version) !== null, 'missing_consent_version');
const purposes = Array.isArray(job.consent_purposes) ? job.consent_purposes : [];
const routingConsent = fail(purposes.includes('installer_routing'), 'missing_installer_routing_consent');
const validZip = fail(ZIP_RE.test(text(job.zip) || ''), 'invalid_or_missing_zip');
const routedTo = fail(positiveIntegerId(job.routed_to), 'missing_routed_to');
const routedAt = fail(validDate(job.routed_at), 'missing_routed_at');
const eligibleState = fail(ELIGIBLE_STATES.has(job.lead_state), 'ineligible_lead_state');
const preference = CONTACT_PREFERENCES.has(job.contact_preference) ? job.contact_preference : null;
if (!preference) reasons.push('missing_or_invalid_contact_preference');
const emailOkay = validEmail(job.customer_email);
const phoneOkay = validPhone(job.customer_phone);
const contactOkay = preference === 'email' ? emailOkay
: preference === 'phone' ? phoneOkay
: preference === 'either' ? (emailOkay || phoneOkay) : false;
if (!contactOkay) reasons.push('missing_valid_preferred_contact');
const hasSqft = positive(job.sqft);
const hasRolls = positive(job.rolls);
if (!hasSqft && !hasRolls) reasons.push('missing_positive_scope');
const measurementMethod = MEASUREMENT_METHODS.has(job.measurement_method);
if (!measurementMethod) reasons.push('missing_or_invalid_measurement_method');
const sharedEligible = production && nonTest && captured && consentedAt && consentVersion
&& routingConsent && validZip && routedTo && routedAt && eligibleState && preference && contactOkay;
const standardEligible = sharedEligible && (hasSqft || hasRolls) && measurementMethod;
if (!standardEligible) {
return { tier: 'ineligible', reason_codes: reasons.sort(), policy_version: POLICY_VERSION };
}
if (hasSqft) reasons.push('has_sqft'); else reasons.push('spec_missing_sqft');
if (hasRolls) reasons.push('has_rolls'); else reasons.push('spec_missing_rolls');
const hasWallCount = positive(job.wall_count);
if (hasWallCount) reasons.push('has_wall_count'); else reasons.push('spec_missing_wall_count');
const hasProductContext = text(job.product_sku) !== null || text(job.material) !== null;
if (hasProductContext) reasons.push('has_product_or_material'); else reasons.push('spec_missing_product_or_material');
const hasTimeline = text(job.timeline_band) !== null;
if (hasTimeline) reasons.push('has_timeline'); else reasons.push('spec_missing_timeline');
const hasSurface = text(job.surface_state) !== null;
if (hasSurface) reasons.push('has_surface_state'); else reasons.push('spec_missing_surface_state');
const captures = Array.isArray(job.room_captures) ? job.room_captures : [];
const hasValidCapture = captures.length > 0 && captures.every(validRoomCapture);
if (hasValidCapture) reasons.push('has_valid_room_capture'); else reasons.push('spec_missing_valid_room_capture');
const specReady = hasSqft && hasRolls && hasWallCount && hasProductContext
&& hasTimeline && hasSurface && hasValidCapture;
return {
tier: specReady ? 'spec_ready' : 'standard',
reason_codes: reasons.sort(),
policy_version: POLICY_VERSION
};
}
module.exports = { POLICY_VERSION, classifyMeasureTier };