koala73/worldmonitor · error · ValidationError

Valid phone number is required

Error message

Valid phone number is required

What it means

Phone format gate (submit-contact.ts:167): the trimmed value must match PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/. Optional leading '+' or '(', then a digit, then 4-23 characters of digits/space/parens/dots/slashes/dashes, ending with a digit — total length 6-25, letters anywhere fail. The input is trimmed but not normalized, so stored formatting is preserved.

Source

Thrown at server/worldmonitor/leads/v1/submit-contact.ts:168

  const { email, name, organization, phone, message, source } = req;

  if (!email || !EMAIL_RE.test(email)) {
    throw new ValidationError([{ field: 'email', description: 'Invalid email' }]);
  }

  const emailDomain = email.split('@')[1]?.toLowerCase();
  if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
    throw new ApiError(422, 'Please use your work email address', '');
  }

  if (!name || name.trim().length === 0) {
    throw new ValidationError([{ field: 'name', description: 'Name is required' }]);
  }
  if (!organization || organization.trim().length === 0) {
    throw new ValidationError([{ field: 'organization', description: 'Company is required' }]);
  }
  if (!phone || !PHONE_RE.test(phone.trim())) {
    throw new ValidationError([{ field: 'phone', description: 'Valid phone number is required' }]);
  }

  const safeName = name.slice(0, MAX_FIELD);
  const safeOrg = organization.slice(0, MAX_FIELD);
  const safePhone = phone.trim().slice(0, 30);
  const safeMsg = message ? message.slice(0, MAX_MESSAGE) : undefined;
  const safeSource = source ? source.slice(0, 100) : 'enterprise-contact';

  const convexUrl = process.env.CONVEX_URL;
  if (!convexUrl) {
    throw new ApiError(503, 'Service unavailable', '');
  }

  const client = new ConvexHttpClient(convexUrl);
  try {
    await client.mutation(api.contactMessages.submit, {
      name: safeName,
      email: email.trim(),

View on GitHub (pinned to a96956387a)

Solutions

  1. Strip non-dialable characters server- or client-side and keep 6-25 chars starting with optional '+'
  2. Move extensions to a separate field
  3. Validate with the exact same regex client-side before submit: /^[+(]?\d[\d\s()./-]{4,23}\d$/.test(phone.trim())
  4. Prefer E.164-style input (+12125551234) which always matches

Example fix

// before
await submitContact({ phone: '(+44) 20 7946 0000', ... }); // '(' then '+' -> ValidationError

// after
await submitContact({ phone: '+44 20 7946 0000', ... }); // matches PHONE_RE
Defensive patterns

Strategy: validation

Validate before calling

const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
const phone = rawPhone.trim();
if (!phone || !PHONE_RE.test(phone)) {
  showFieldError('phone', 'Enter a valid phone number (6-25 chars, digits and + ( ) . / - only, no extensions)');
  return;
}
await submitContact({ ...form, phone });

Type guard

function isContactPhone(v: unknown): v is string {
  return typeof v === 'string' && /^[+(]?\d[\d\s()./-]{4,23}\d$/.test(v.trim());
}

Try / catch

try { await submitContact(form); }
catch (e) {
  if (isValidationError(e)) {
    for (const v of e.violations ?? []) if (v.field === 'phone') showFieldError('phone', v.description);
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Extensions or labels containing letters ("+1 555 123 4567 ext. 9"); a string shorter than 6 chars ("12345"); trailing punctuation ("555-1234-"); '(+44) 20 7946 0000' — after '(' the next char must be a digit, so '+' fails; values longer than 25 chars; empty phone.

Common situations: International formats with '(' plus '+' combined; users pasting numbers with extension notes; UIs that capture phone as free text without a mask; edge cases like '00 44 20 7946 0000' which actually pass (leading 0 is a digit) while '(+...' does not.

Related errors


AI-assisted analysis of koala73/worldmonitor@a96956387a (2026-08-21). Data as JSON: /api/errors/7916bbba6bfcc065. Report an issue: GitHub.