← 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 };