nordicstorium/src/lib/validation.ts

210 lines
6.8 KiB
TypeScript

/**
* Validates a Swedish Personnummer using the Modulo 10 (Luhn) algorithm.
* Now also verifies that the date component is a valid calendar date.
*/
export function validatePersonnummer(pin: string): boolean {
const cleanPin = pin.replace(/\D/g, '');
// 1. Length Check (12 digits: YYYYMMDDXXXX)
if (cleanPin.length !== 12) return false;
// 2. Date Check (YYYYMMDD)
const year = parseInt(cleanPin.substring(0, 4));
const month = parseInt(cleanPin.substring(4, 6));
const day = parseInt(cleanPin.substring(6, 8));
if (month < 1 || month > 12) return false;
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return false;
}
// 3. Luhn Check (on the last 10 digits)
const pin10 = cleanPin.substring(2);
let sum = 0;
for (let i = 0; i < 10; i++) {
let val = parseInt(pin10[i]);
if (i % 2 === 0) {
val *= 2;
if (val > 9) val -= 9;
}
sum += val;
}
return sum % 10 === 0;
}
/**
* Validates a Swedish address format (Street Name + Number)
*/
export function validateAddress(address: string): boolean {
// Expects "Streetname 12" or "Street 1B", etc.
const addressRegex = /^.{2,}\s\d+[a-zA-Z]?.*$/;
return addressRegex.test(address.trim());
}
/**
* Fetches Swedish city data for a given 5-digit postcode.
* Uses the free Zippopotam.us API.
*/
export async function lookupSwedishCity(zipCode: string): Promise<string | null> {
const cleanZip = zipCode.replace(/\D/g, '');
if (cleanZip.length !== 5) return null;
try {
const response = await fetch(`https://api.zippopotam.us/se/${cleanZip}`);
if (!response.ok) return null;
const data = await response.json();
if (data.places && data.places.length > 0) {
return data.places[0]['place name'];
}
return null;
} catch (error) {
console.error('Postcode lookup error:', error);
return null;
}
}
/**
* Normalizes a Swedish address to increase map matching success.
* Strips apartment numbers, floor info, and standardizes spacing.
* Example: "Storgatan 12, lgh 1201" -> "Storgatan 12"
*/
export function normalizeSwedishAddress(address: string): string {
// 1. Remove common Swedish apartment/floor prefixes and everything after them
// (lgh, vån, trappa, tr, oppgång, etc.)
let normalized = address.toLowerCase();
const suffixes = [
',', ' lgh', ' vån', ' tr', ' trappa', ' nr', ' uppgång',
' floor', ' apt', ' suite', ' bvx', ' box'
];
for (const suffix of suffixes) {
const index = normalized.indexOf(suffix);
if (index !== -1) {
normalized = normalized.substring(0, index);
}
}
// 2. Standardize house numbers (ensure space before letters, e.g. "12A" -> "12 A")
// Note: Nominatim handles "12A" well, but standardizing can help some older data.
// However, the most important part is removing the "noise" after the number.
return normalized.trim();
}
/**
* Normalizes a Swedish mobile number to the format 467XXXXXXXX.
* Handles +, 00, 07 prefix and whitespace/hyphens.
*/
export function normalizeSwedishMobile(mobile: string): string {
// 1. Remove all non-numeric characters
let clean = mobile.replace(/\D/g, '');
// 2. Handle international prefixes
if (clean.startsWith('0046')) {
clean = clean.substring(2);
} else if (clean.startsWith('07')) {
// Change 07... to 467...
clean = '46' + clean.substring(1);
}
// Note: If it already starts with 467, we leave it as is.
// Minimal length for a Swedish mobile is 46 + 9 digits = 11 digits
return clean;
}
/**
* Validates if the string is a valid Swedish mobile number.
* Allows various formats but normalizes them first.
*/
export function validateSwedishMobile(mobile: string): boolean {
const normalized = normalizeSwedishMobile(mobile);
// Swedish mobile numbers (including 46 prefix) are typically 11 digits
// Format: 46 7X XXX XX XX
const mobileRegex = /^467\d{8}$/;
return mobileRegex.test(normalized);
}
/**
* Verifies if a specific street address exists in a given Swedish city/zip.
* Uses Nominatim (OpenStreetMap) API with a multi-fallback strategy.
*/
export async function lookupSwedishAddress(street: string, zipCode: string, city: string): Promise<boolean> {
const cleanZip = zipCode.replace(/\D/g, '');
const normalizedStreet = normalizeSwedishAddress(street);
// Helper to call Nominatim with varying parameters
const fetchFromNominatim = async (s: string, useCity: boolean) => {
const params: Record<string, string> = {
street: s,
postalcode: cleanZip,
country: 'Sweden',
format: 'json',
addressdetails: '1', // Get detailed address info
limit: '5' // Check a few results in case the first one is a different zip
};
if (useCity) params.city = city;
const urlParams = new URLSearchParams(params);
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?${urlParams.toString()}`, {
headers: { 'User-Agent': 'NordicStorium-App-Validation' }
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
};
interface NominatimResult {
address?: {
postcode?: string;
};
}
// Helper to verify that at least one result matches the input zip code
const hasZipMatch = (data: NominatimResult[]) => {
if (!data || data.length === 0) return false;
// Check if any of the results have a matching postcode (handling spaces like "123 45")
return data.some(item => {
const resultZip = (item.address?.postcode || '').replace(/\s/g, '');
return resultZip === cleanZip;
});
};
try {
// Attempt 1: Normalized Exactly (Street + Number) + Zip
const try1 = await fetchFromNominatim(normalizedStreet, false);
if (hasZipMatch(try1)) return true;
// Attempt 2: Street Name only + Zip
const streetNameMatch = normalizedStreet.match(/^([^\d]+)/);
if (streetNameMatch) {
const streetName = streetNameMatch[1].trim();
if (streetName.length > 2) {
const try2 = await fetchFromNominatim(streetName, false);
if (hasZipMatch(try2)) return true;
}
}
// Attempt 3: Include City (last resort)
const try3 = await fetchFromNominatim(normalizedStreet, true);
if (hasZipMatch(try3)) return true;
return false;
} catch {
return true; // Fail open
}
}