Fix phone sanitization and signal group lookup
This commit is contained in:
parent
d83c1af258
commit
31a3b505af
3 changed files with 81 additions and 17 deletions
|
|
@ -100,19 +100,57 @@ export const Zammad = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
/**
|
||||||
// Sanitize phone number: only allow digits and + symbol
|
* Sanitizes phone number to E.164 format: +15554446666
|
||||||
const mungedNumber = phoneNumber.replace(/[^\d+]/g, "");
|
* Strips all non-digit characters except +, ensures + prefix
|
||||||
|
* @param phoneNumber - Raw phone number (e.g., "(555) 444-6666", "5554446666", "+1 555 444 6666")
|
||||||
|
* @returns E.164 formatted phone number (e.g., "+15554446666")
|
||||||
|
* @throws Error if phone number is invalid
|
||||||
|
*/
|
||||||
|
export const sanitizePhoneNumber = (phoneNumber: string): string => {
|
||||||
|
// Remove all characters except digits and +
|
||||||
|
let cleaned = phoneNumber.replace(/[^\d+]/g, "");
|
||||||
|
|
||||||
// Validate phone number format (10-15 digits, optional + prefix)
|
// Ensure it starts with +
|
||||||
if (!/^\+?\d{10,15}$/.test(mungedNumber)) {
|
if (!cleaned.startsWith("+")) {
|
||||||
|
// Assume US/Canada if no country code (11 digits starting with 1, or 10 digits)
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
cleaned = "+1" + cleaned;
|
||||||
|
} else if (cleaned.length === 11 && cleaned.startsWith("1")) {
|
||||||
|
cleaned = "+" + cleaned;
|
||||||
|
} else if (cleaned.length >= 10) {
|
||||||
|
// International number without +, add it
|
||||||
|
cleaned = "+" + cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate E.164 format: + followed by 10-15 digits
|
||||||
|
if (!/^\+\d{10,15}$/.test(cleaned)) {
|
||||||
throw new Error(`Invalid phone number format: ${phoneNumber}`);
|
throw new Error(`Invalid phone number format: ${phoneNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove + for search query
|
return cleaned;
|
||||||
const searchNumber = mungedNumber.replace("+", "");
|
};
|
||||||
const results = await zammad.user.search(`phone:${searchNumber}`);
|
|
||||||
|
export const getUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||||
|
// Sanitize to E.164 format
|
||||||
|
const sanitized = sanitizePhoneNumber(phoneNumber);
|
||||||
|
|
||||||
|
// Remove + for Zammad search query
|
||||||
|
const searchNumber = sanitized.replace("+", "");
|
||||||
|
|
||||||
|
// Try sanitized format first (e.g., "6464229653" for "+16464229653")
|
||||||
|
let results = await zammad.user.search(`phone:${searchNumber}`);
|
||||||
if (results.length > 0) return results[0];
|
if (results.length > 0) return results[0];
|
||||||
|
|
||||||
|
// Fall back to searching for original input (handles legacy formatted numbers)
|
||||||
|
// This ensures we can find users with "(646) 422-9653" format in database
|
||||||
|
const originalCleaned = phoneNumber.replace(/[^\d+]/g, "").replace("+", "");
|
||||||
|
if (originalCleaned !== searchNumber) {
|
||||||
|
results = await zammad.user.search(`phone:${originalCleaned}`);
|
||||||
|
if (results.length > 0) return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -123,8 +161,11 @@ export const getOrCreateUser = async (
|
||||||
const customer = await getUser(zammad, phoneNumber);
|
const customer = await getUser(zammad, phoneNumber);
|
||||||
if (customer) return customer;
|
if (customer) return customer;
|
||||||
|
|
||||||
|
// Sanitize phone number to E.164 format before storing
|
||||||
|
const sanitized = sanitizePhoneNumber(phoneNumber);
|
||||||
|
|
||||||
return zammad.user.create({
|
return zammad.user.create({
|
||||||
phone: phoneNumber,
|
phone: sanitized,
|
||||||
note: "User created from incoming voice call",
|
note: "User created from incoming voice call",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db } from "@link-stack/bridge-common";
|
||||||
import { Zammad, getUser } from "../../lib/zammad.js";
|
import { Zammad, getUser, sanitizePhoneNumber } from "../../lib/zammad.js";
|
||||||
import {
|
import {
|
||||||
loadFieldMapping,
|
loadFieldMapping,
|
||||||
getFieldValue,
|
getFieldValue,
|
||||||
|
|
@ -55,12 +55,35 @@ const createTicketFromFormTask = async (
|
||||||
|
|
||||||
// Extract well-known fields used for special logic (all optional)
|
// Extract well-known fields used for special logic (all optional)
|
||||||
const email = getFieldValue(formData, "email", mapping);
|
const email = getFieldValue(formData, "email", mapping);
|
||||||
const phone = getFieldValue(formData, "phone", mapping);
|
const rawPhone = getFieldValue(formData, "phone", mapping);
|
||||||
const signalAccount = getFieldValue(formData, "signalAccount", mapping);
|
const rawSignalAccount = getFieldValue(formData, "signalAccount", mapping);
|
||||||
const organization = getFieldValue(formData, "organization", mapping);
|
const organization = getFieldValue(formData, "organization", mapping);
|
||||||
const typeOfSupport = getFieldValue(formData, "typeOfSupport", mapping);
|
const typeOfSupport = getFieldValue(formData, "typeOfSupport", mapping);
|
||||||
const descriptionOfIssue = getFieldValue(formData, "descriptionOfIssue", mapping);
|
const descriptionOfIssue = getFieldValue(formData, "descriptionOfIssue", mapping);
|
||||||
|
|
||||||
|
// Sanitize phone numbers to E.164 format (+15554446666)
|
||||||
|
let phone: string | undefined;
|
||||||
|
if (rawPhone) {
|
||||||
|
try {
|
||||||
|
phone = sanitizePhoneNumber(rawPhone);
|
||||||
|
logger.info({ rawPhone, sanitized: phone }, "Sanitized phone number");
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn({ rawPhone, error: error.message }, "Invalid phone number format, ignoring");
|
||||||
|
phone = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let signalAccount: string | undefined;
|
||||||
|
if (rawSignalAccount) {
|
||||||
|
try {
|
||||||
|
signalAccount = sanitizePhoneNumber(rawSignalAccount);
|
||||||
|
logger.info({ rawSignalAccount, sanitized: signalAccount }, "Sanitized signal account");
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn({ rawSignalAccount, error: error.message }, "Invalid signal account format, ignoring");
|
||||||
|
signalAccount = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate that at least one contact method is provided
|
// Validate that at least one contact method is provided
|
||||||
if (!email && !phone && !signalAccount) {
|
if (!email && !phone && !signalAccount) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -222,11 +222,11 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
Rails.logger.info "Channel ID: #{channel.id}"
|
Rails.logger.info "Channel ID: #{channel.id}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
# Use PostgreSQL JSONB queries to efficiently search preferences without loading all tickets into memory
|
# Use text search on preferences YAML to efficiently find tickets without loading all into memory
|
||||||
# This prevents DoS attacks from memory exhaustion
|
# This prevents DoS attacks from memory exhaustion
|
||||||
ticket = Ticket.where.not(state_id: state_ids)
|
ticket = Ticket.where.not(state_id: state_ids)
|
||||||
.where("preferences->>'channel_id' = ?", channel.id.to_s)
|
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
|
||||||
.where("preferences->'cdr_signal'->>'chat_id' = ?", receiver_phone_number)
|
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
|
||||||
.order(updated_at: :desc)
|
.order(updated_at: :desc)
|
||||||
.first
|
.first
|
||||||
|
|
||||||
|
|
@ -420,11 +420,11 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find ticket(s) with this group_id in preferences
|
# Find ticket(s) with this group_id in preferences
|
||||||
# Use PostgreSQL JSONB queries for efficient lookup (prevents DoS from loading all tickets)
|
# Use text search on preferences YAML for efficient lookup (prevents DoS from loading all tickets)
|
||||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||||
|
|
||||||
ticket = Ticket.where.not(state_id: state_ids)
|
ticket = Ticket.where.not(state_id: state_ids)
|
||||||
.where("preferences->'cdr_signal'->>'chat_id' = ?", params[:group_id])
|
.where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%")
|
||||||
.order(updated_at: :desc)
|
.order(updated_at: :desc)
|
||||||
.first
|
.first
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue