171 lines
4.7 KiB
TypeScript
171 lines
4.7 KiB
TypeScript
/* eslint-disable camelcase,@typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any */
|
|
import querystring from "querystring";
|
|
import Wreck from "@hapi/wreck";
|
|
|
|
export interface User {
|
|
id: number;
|
|
firstname?: string;
|
|
lastname?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
}
|
|
export interface Ticket {
|
|
id: number;
|
|
title?: string;
|
|
group_id?: number;
|
|
customer_id?: number;
|
|
}
|
|
|
|
export interface ZammadClient {
|
|
ticket: {
|
|
create: (data: any) => Promise<Ticket>;
|
|
update: (id: number, data: any) => Promise<Ticket>;
|
|
};
|
|
user: {
|
|
search: (data: any) => Promise<User[]>;
|
|
create: (data: any) => Promise<User>;
|
|
};
|
|
get: (path: string) => Promise<any>;
|
|
}
|
|
|
|
export type ZammadCredentials =
|
|
| { username: string; password: string }
|
|
| { token: string };
|
|
|
|
export interface ZammadClientOpts {
|
|
headers?: Record<string, any>;
|
|
}
|
|
|
|
const formatAuth = (credentials: any) => {
|
|
if (credentials.username) {
|
|
return (
|
|
"Basic " +
|
|
Buffer.from(`${credentials.username}:${credentials.password}`).toString(
|
|
"base64",
|
|
)
|
|
);
|
|
}
|
|
|
|
if (credentials.token) {
|
|
return `Token ${credentials.token}`;
|
|
}
|
|
|
|
throw new Error("invalid zammad credentials type");
|
|
};
|
|
|
|
export const Zammad = (
|
|
credentials: ZammadCredentials,
|
|
host: string,
|
|
opts?: ZammadClientOpts,
|
|
): ZammadClient => {
|
|
const extraHeaders = (opts && opts.headers) || {};
|
|
|
|
const wreck = Wreck.defaults({
|
|
baseUrl: `${host}/api/v1/`,
|
|
headers: {
|
|
authorization: formatAuth(credentials),
|
|
...extraHeaders,
|
|
},
|
|
json: true,
|
|
});
|
|
|
|
return {
|
|
ticket: {
|
|
create: async (payload) => {
|
|
const { payload: result } = await wreck.post("tickets", { payload });
|
|
return result as Ticket;
|
|
},
|
|
update: async (id, payload) => {
|
|
const { payload: result } = await wreck.put(`tickets/${id}`, {
|
|
payload,
|
|
});
|
|
return result as Ticket;
|
|
},
|
|
},
|
|
user: {
|
|
search: async (query) => {
|
|
const qp = querystring.stringify({ query });
|
|
const { payload: result } = await wreck.get(`users/search?${qp}`);
|
|
return result as User[];
|
|
},
|
|
create: async (payload) => {
|
|
const { payload: result } = await wreck.post("users", { payload });
|
|
return result as User;
|
|
},
|
|
},
|
|
get: async (path) => {
|
|
const { payload: result } = await wreck.get(path);
|
|
return result;
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Sanitizes phone number to E.164 format: +15554446666
|
|
* 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, "");
|
|
|
|
// Ensure it starts with +
|
|
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}`);
|
|
}
|
|
|
|
return cleaned;
|
|
};
|
|
|
|
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];
|
|
|
|
// 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;
|
|
};
|
|
|
|
export const getOrCreateUser = async (
|
|
zammad: ZammadClient,
|
|
phoneNumber: string,
|
|
) => {
|
|
const customer = await getUser(zammad, phoneNumber);
|
|
if (customer) return customer;
|
|
|
|
// Sanitize phone number to E.164 format before storing
|
|
const sanitized = sanitizePhoneNumber(phoneNumber);
|
|
|
|
return zammad.user.create({
|
|
phone: sanitized,
|
|
note: "User created from incoming voice call",
|
|
});
|
|
};
|