link-stack/apps/bridge-worker/lib/zammad.ts
2025-11-20 13:12:56 +01:00

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",
});
};