All files / src/lib profile-validation.ts

100% Statements 34/34
100% Branches 20/20
100% Functions 3/3
100% Lines 30/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103                                          2x     2x     2x     2x       2x 2x   2x                           16x 1x   15x 15x 13x 4x   9x                       17x 1x   16x 13x     13x 6x                 14x 1x   13x 13x 8x 2x   6x 6x 2x   4x    
// Client-side profile-field validators for the marketplace Edit Profile form.
//
// IMPORTANT: the API enforces the same rules via Zod `.refine()` /
// `freeTextSchema` in `apps/api/src/routes/homeowner/profile.routes.ts`.
//
// Two patterns coexist here:
//   - displayName + phone predate the shared `@interioring/utils` package and
//     are still hand-duplicated against the API's Zod regexes (see #382). If
//     you edit those regexes, the digit minimum, or the accepted-empty
//     semantics, update the Zod schema too.
//   - Newer free-text fields (locality, …) consume the shared
//     `validateFreeText` helper directly, which is the same building block the
//     API's `freeTextSchema` wraps. Drift is impossible by construction —
//     prefer this pattern when adding fields.
 
import {
	freeTextErrorMessage,
	validateFreeText,
} from "@interioring/utils/validation/free-text";
 
/** Allowed phone characters (optional `+`, digits, spaces, hyphens, parens). */
const PHONE_ALLOWED = /^\+?[\d\s()-]+$/;
 
/** Minimum digit count for a phone to be considered a real number. */
const PHONE_MIN_DIGITS = 7;
 
/** Maximum raw-string length (matches the existing Zod `.max(15)` on the API). */
const PHONE_MAX_LENGTH = 15;
 
/** Display-name maximum length (matches the existing Zod `.max(100)`). */
const DISPLAY_NAME_MAX_LENGTH = 100;
 
/** Locality limits — match `freeTextSchema({minLen:2,requireLetter:true,maxLen:100})`
 *  applied server-side in `apps/api/src/routes/homeowner/profile.routes.ts`. */
const LOCALITY_OPTS = { minLen: 2, requireLetter: true } as const;
const LOCALITY_MAX_LENGTH = 100;
 
export const PROFILE_VALIDATION_MESSAGES = {
	displayName:
		"Please enter a valid name (letters are required, not just digits or symbols).",
	phone:
		"Enter a valid phone number — digits only, with an optional leading + and spaces or hyphens.",
} as const;
 
/**
 * Returns an error string if the display name is invalid, or null if valid.
 * Empty string is treated as "cleared" and is accepted (the field is optional).
 */
export function validateDisplayName(value: string): string | null {
	// Zod's `.max()` runs on the raw value — match its semantics here so the
	// client and server agree on boundary-length input.
	if (value.length > DISPLAY_NAME_MAX_LENGTH) {
		return `Name must be ${DISPLAY_NAME_MAX_LENGTH} characters or fewer.`;
	}
	const trimmed = value.trim();
	if (trimmed === "") return null;
	if (!/\p{L}/u.test(trimmed)) {
		return PROFILE_VALIDATION_MESSAGES.displayName;
	}
	return null;
}
 
/**
 * Returns an error string if the locality is invalid, or null if valid.
 * Empty / whitespace-only is treated as "cleared" and is accepted (the field
 * is optional). Mirrors the server-side `freeTextSchema` rule from
 * `apps/api/src/routes/homeowner/profile.routes.ts` so users see the same
 * rejection inline before submit instead of as a generic "Failed to save"
 * banner after a 400 response.
 */
export function validateLocality(value: string): string | null {
	if (value.length > LOCALITY_MAX_LENGTH) {
		return `Must be ${LOCALITY_MAX_LENGTH} characters or fewer.`;
	}
	if (value.trim() === "") return null;
	const error = validateFreeText(value, LOCALITY_OPTS);
	// Shared helper returns un-punctuated messages; append a period so this
	// reads consistently next to displayName/phone errors in the same form.
	if (error) return `${freeTextErrorMessage(error, LOCALITY_OPTS)}.`;
	return null;
}
 
/**
 * Returns an error string if the phone is invalid, or null if valid.
 * Empty string is treated as "cleared" and is accepted.
 */
export function validatePhone(value: string): string | null {
	// Zod's `.max()` runs on the raw value — match its semantics here.
	if (value.length > PHONE_MAX_LENGTH) {
		return `Phone must be ${PHONE_MAX_LENGTH} characters or fewer.`;
	}
	const trimmed = value.trim();
	if (trimmed === "") return null;
	if (!PHONE_ALLOWED.test(trimmed)) {
		return PROFILE_VALIDATION_MESSAGES.phone;
	}
	const digits = trimmed.replace(/\D/g, "");
	if (digits.length < PHONE_MIN_DIGITS) {
		return PROFILE_VALIDATION_MESSAGES.phone;
	}
	return null;
}