PSA/shared/models/contactModel.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

1765 lines
63 KiB
TypeScript

/**
* Shared Contact Model - Core business logic for contact operations
* This model contains the essential contact business logic extracted from
* server actions and used by both server actions and workflow actions.
*/
import { Knex } from 'knex';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import {
CONTACT_PHONE_CANONICAL_TYPES,
type ContactPhoneCanonicalType,
type ContactPhoneNumberInput,
CONTACT_EMAIL_CANONICAL_TYPES,
type ContactEmailCanonicalType,
type IContactEmailAddress,
type ContactEmailAddressInput,
type IContact,
type IContactPhoneNumber,
type CreateContactInput,
type UpdateContactInput,
} from '../interfaces/contact.interfaces';
import { ValidationResult } from '../interfaces/validation.interfaces';
const canonicalPhoneTypeSchema = z.enum(CONTACT_PHONE_CANONICAL_TYPES);
const canonicalEmailTypeSchema = z.enum(CONTACT_EMAIL_CANONICAL_TYPES);
const phoneRowInputSchema = z.object({
contact_phone_number_id: z.string().uuid().optional(),
phone_number: z.string().trim().min(1, 'Phone number is required'),
canonical_type: canonicalPhoneTypeSchema.nullish(),
custom_type: z.string().trim().min(1).nullish(),
is_default: z.boolean().optional(),
display_order: z.number().int().min(0).optional(),
});
const emailRowInputSchema = z.object({
contact_additional_email_address_id: z.string().uuid().optional(),
email_address: z.string().trim().min(1, 'Email address is required'),
canonical_type: canonicalEmailTypeSchema.nullish(),
custom_type: z.string().trim().min(1).nullish(),
display_order: z.number().int().min(0).optional(),
});
// =============================================================================
// VALIDATION SCHEMAS
// =============================================================================
export const contactFormSchema = z.object({
full_name: z.string().trim().min(1, 'Full name is required'),
// Validate with the same pattern the action/model/client all use, and surface a
// single clear message. A z.union([...email(), '', null]) here reported a confusing
// wrapped "Validation failed: email: Invalid email address" and could diverge from
// the regex used elsewhere, leaving users with an unhelpful error on a bad email.
email: z
.string()
.trim()
.nullable()
.optional()
.refine(
(value) => value == null || value === '' || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
{ message: 'Please enter a valid email address' }
),
primary_email_canonical_type: canonicalEmailTypeSchema.nullish(),
primary_email_custom_type: z.string().trim().min(1).nullish(),
primary_email_custom_type_id: z.string().uuid().nullable().optional(),
additional_email_addresses: z.array(emailRowInputSchema).optional(),
phone_numbers: z.array(phoneRowInputSchema).optional(),
client_id: z.string().uuid('Client ID must be a valid UUID').optional().nullable(),
inbound_ticket_defaults_id: z.string().uuid('Inbound ticket destination must be a valid UUID').nullable().optional(),
role: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
is_inactive: z.boolean().optional(),
});
export const contactSchema = z.object({
contact_name_id: z.string().uuid(),
tenant: z.string().uuid(),
full_name: z.string(),
client_id: z.string().uuid().nullable(),
primary_email_canonical_type: canonicalEmailTypeSchema.nullable(),
primary_email_custom_type_id: z.string().uuid().nullable().optional(),
primary_email_type: z.string().nullable().optional(),
additional_email_addresses: z.array(
z.object({
contact_additional_email_address_id: z.string().uuid(),
email_address: z.string(),
normalized_email_address: z.string(),
canonical_type: canonicalEmailTypeSchema.nullable(),
custom_email_type_id: z.string().uuid().nullable().optional(),
custom_type: z.string().nullable(),
display_order: z.number().int().min(0),
created_at: z.string().optional(),
updated_at: z.string().optional(),
})
),
phone_numbers: z.array(
z.object({
contact_phone_number_id: z.string().uuid(),
phone_number: z.string(),
normalized_phone_number: z.string(),
canonical_type: canonicalPhoneTypeSchema.nullable(),
custom_phone_type_id: z.string().uuid().nullable().optional(),
custom_type: z.string().nullable(),
is_default: z.boolean(),
display_order: z.number().int().min(0),
created_at: z.string().optional(),
updated_at: z.string().optional(),
})
),
default_phone_number: z.string().nullable().optional(),
default_phone_type: z.string().nullable().optional(),
email: z.string().nullable(),
role: z.string().nullable(),
notes: z.string().nullable(),
is_inactive: z.boolean().nullable(),
created_at: z.string(),
updated_at: z.string(),
});
export const contactUpdateSchema = contactFormSchema.partial();
export type {
IContact,
IContactPhoneNumber,
IContactEmailAddress,
ContactEmailAddressInput,
ContactPhoneNumberInput,
CreateContactInput,
UpdateContactInput,
} from '../interfaces/contact.interfaces';
export type { ValidationResult } from '../interfaces/validation.interfaces';
type ContactRecord = {
contact_name_id: string;
tenant: string;
full_name: string;
client_id: string | null;
primary_email_canonical_type: ContactEmailCanonicalType | null;
primary_email_custom_type_id: string | null;
primary_email_type?: string | null;
email: string | null;
role: string | null;
notes: string | null;
is_inactive: boolean | null;
created_at: string;
updated_at: string;
[key: string]: any;
};
type ContactPhoneRow = {
contact_phone_number_id: string;
contact_name_id: string;
phone_number: string;
normalized_phone_number: string;
canonical_type: ContactPhoneCanonicalType | null;
custom_phone_type_id: string | null;
custom_type: string | null;
is_default: boolean;
display_order: number;
created_at?: string;
updated_at?: string;
};
type ContactEmailRow = {
contact_additional_email_address_id: string;
contact_name_id?: string;
email_address: string;
normalized_email_address: string;
canonical_type: ContactEmailCanonicalType | null;
custom_email_type_id?: string | null;
custom_type: string | null;
display_order: number;
created_at?: string;
updated_at?: string;
};
type PreparedPhoneNumberInput = {
contact_phone_number_id?: string;
phone_number: string;
canonical_type: ContactPhoneCanonicalType | null;
custom_type: string | null;
normalized_custom_type: string | null;
is_default: boolean;
display_order: number;
};
type PreparedEmailAddressInput = {
contact_additional_email_address_id?: string;
email_address: string;
normalized_email_address: string;
canonical_type: ContactEmailCanonicalType | null;
custom_type: string | null;
normalized_custom_type: string | null;
custom_email_type_id?: string | null;
display_order: number;
};
type PreparedPrimaryEmailTypeInput = {
canonicalType: ContactEmailCanonicalType | null;
customTypeId: string | null;
customType: string | null;
normalizedCustomType: string | null;
};
type ContactWithPhones = ContactRecord & Pick<IContact, 'phone_numbers' | 'default_phone_number' | 'default_phone_type'>;
const phonePattern = /^[0-9A-Za-z+().\-#\s/]+$/;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// =============================================================================
// VALIDATION HELPER FUNCTIONS
// =============================================================================
export function validateData<T>(schema: z.ZodSchema<T>, data: unknown): T {
try {
return schema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ');
throw new Error(`Validation failed: ${errorMessages}`);
}
throw error;
}
}
export function cleanNullableFields(data: Record<string, any>): Record<string, any> {
const cleaned = { ...data };
const nullableFields = [
'email', 'client_id', 'role',
'title', 'department', 'notes', 'login_email',
];
for (const field of nullableFields) {
if (cleaned[field] === '') {
cleaned[field] = null;
}
}
return cleaned;
}
export function parseFullName(fullName: string): { firstName: string; lastName: string } {
const nameParts = fullName.trim().split(/\s+/);
if (nameParts.length === 1) {
return {
firstName: nameParts[0],
lastName: '',
};
}
return {
firstName: nameParts[0],
lastName: nameParts.slice(1).join(' '),
};
}
function normalizeCustomTypeLabel(label: string): string {
return label.trim().replace(/\s+/g, ' ').toLowerCase();
}
function normalizePhoneForSearch(phoneNumber: string): string {
return phoneNumber.replace(/\D/g, '');
}
function normalizeEmailAddress(emailAddress: string): string {
return emailAddress.trim().toLowerCase();
}
function toPreparedEmailAddressInput(row: ContactEmailRow): PreparedEmailAddressInput {
return {
contact_additional_email_address_id: row.contact_additional_email_address_id,
email_address: row.email_address,
normalized_email_address: row.normalized_email_address,
canonical_type: row.canonical_type,
custom_type: row.custom_type,
normalized_custom_type: row.custom_type ? normalizeCustomTypeLabel(row.custom_type) : null,
custom_email_type_id: row.custom_email_type_id ?? null,
display_order: row.display_order,
};
}
function deriveDefaultPhoneType(phoneNumber: Pick<IContactPhoneNumber, 'canonical_type' | 'custom_type'> | undefined): string | null {
if (!phoneNumber) return null;
return phoneNumber.custom_type ?? phoneNumber.canonical_type ?? null;
}
function derivePrimaryEmailType(
primaryCanonicalType: ContactEmailCanonicalType | null,
primaryCustomTypeId: string | null,
customTypeMap: Map<string, string>,
): string | null {
if (primaryCustomTypeId) {
return customTypeMap.get(primaryCustomTypeId) ?? null;
}
return primaryCanonicalType;
}
// =============================================================================
// CORE CONTACT MODEL
// =============================================================================
export class ContactModel {
static readonly canonicalPhoneTypes = CONTACT_PHONE_CANONICAL_TYPES;
static readonly canonicalEmailTypes = CONTACT_EMAIL_CANONICAL_TYPES;
static validateCreateContactInput(input: CreateContactInput): ValidationResult {
try {
if (!input.full_name || input.full_name.trim() === '') {
return { valid: false, errors: ['Full name is required'] };
}
const cleanedInput = cleanNullableFields(input as Record<string, any>);
const validatedData = validateData(contactFormSchema, cleanedInput);
const phoneValidation = this.validatePhoneNumbers(validatedData.phone_numbers as ContactPhoneNumberInput[] | undefined);
if (!phoneValidation.valid) {
return phoneValidation;
}
const emailValidation = this.validateEmailAddressPayload(validatedData.additional_email_addresses as ContactEmailAddressInput[] | undefined, {
primaryEmail: validatedData.email,
});
if (!emailValidation.valid) {
return emailValidation;
}
const primaryTypeValidation = this.validatePrimaryEmailTypeInput(
validatedData.primary_email_canonical_type,
validatedData.primary_email_custom_type_id,
validatedData.primary_email_custom_type
);
if (!primaryTypeValidation.valid) {
return primaryTypeValidation;
}
return {
valid: true,
data: {
...validatedData,
phone_numbers: phoneValidation.data ?? [],
additional_email_addresses: emailValidation.data ?? [],
primary_email_canonical_type: primaryTypeValidation.data?.canonicalType ?? null,
primary_email_custom_type: primaryTypeValidation.data?.customType ?? null,
primary_email_custom_type_id: primaryTypeValidation.data?.customTypeId ?? null,
},
};
} catch (error) {
return {
valid: false,
errors: [error instanceof Error ? error.message : 'Validation failed'],
};
}
}
static validateUpdateContactInput(input: UpdateContactInput): ValidationResult {
try {
const cleanedInput = cleanNullableFields(input as Record<string, any>);
const validatedData = validateData(contactUpdateSchema, cleanedInput);
const phoneValidation = this.validatePhoneNumbers(validatedData.phone_numbers as ContactPhoneNumberInput[] | undefined, { allowUndefined: true });
if (!phoneValidation.valid) {
return phoneValidation;
}
const emailValidation = this.validateEmailAddressPayload(validatedData.additional_email_addresses as ContactEmailAddressInput[] | undefined, {
allowUndefined: true,
});
if (!emailValidation.valid) {
return emailValidation;
}
const primaryTypeValidation = this.validatePrimaryEmailTypeInput(
validatedData.primary_email_canonical_type,
validatedData.primary_email_custom_type_id,
validatedData.primary_email_custom_type
);
if (!primaryTypeValidation.valid) {
return primaryTypeValidation;
}
return {
valid: true,
data: {
...validatedData,
...(phoneValidation.data !== undefined ? { phone_numbers: phoneValidation.data } : {}),
...(emailValidation.data !== undefined ? { additional_email_addresses: emailValidation.data } : {}),
...(validatedData.primary_email_canonical_type !== undefined || validatedData.primary_email_custom_type_id !== undefined
? {
primary_email_canonical_type: primaryTypeValidation.data?.canonicalType ?? null,
primary_email_custom_type: primaryTypeValidation.data?.customType ?? null,
primary_email_custom_type_id: primaryTypeValidation.data?.customTypeId ?? null,
}
: {}),
},
};
} catch (error) {
return {
valid: false,
errors: [error instanceof Error ? error.message : 'Validation failed'],
};
}
}
static validatePhoneNumbers(
phoneNumbers: ContactPhoneNumberInput[] | undefined,
options: { allowUndefined?: boolean } = {}
): ValidationResult {
if (phoneNumbers === undefined) {
return options.allowUndefined ? { valid: true, data: undefined } : { valid: true, data: [] };
}
if (!Array.isArray(phoneNumbers)) {
return { valid: false, errors: ['Phone numbers must be an array'] };
}
const normalizedRows: PreparedPhoneNumberInput[] = [];
const seenCustomLabels = new Set<string>();
let defaultCount = 0;
for (let index = 0; index < phoneNumbers.length; index += 1) {
const row = phoneNumbers[index];
const parsedRow = phoneRowInputSchema.safeParse(row);
if (!parsedRow.success) {
return {
valid: false,
errors: parsedRow.error.errors.map(error => `phone_numbers.${index}.${error.path.join('.')}: ${error.message}`),
};
}
const trimmedPhoneNumber = parsedRow.data.phone_number.trim();
const normalizedDigits = normalizePhoneForSearch(trimmedPhoneNumber);
if (!normalizedDigits) {
return { valid: false, errors: [`phone_numbers.${index}.phone_number: Phone number must contain at least one digit`] };
}
if (!phonePattern.test(trimmedPhoneNumber)) {
return { valid: false, errors: [`phone_numbers.${index}.phone_number: Phone number contains unsupported characters`] };
}
const canonicalType = parsedRow.data.canonical_type ?? null;
const customType = parsedRow.data.custom_type?.trim() || null;
if (!canonicalType && !customType) {
return { valid: false, errors: [`phone_numbers.${index}: Choose a canonical type or provide a custom type`] };
}
if (canonicalType && customType) {
return { valid: false, errors: [`phone_numbers.${index}: A phone row cannot have both canonical and custom types`] };
}
const normalizedCustomType = customType ? normalizeCustomTypeLabel(customType) : null;
if (normalizedCustomType && ContactModel.canonicalPhoneTypes.includes(normalizedCustomType as ContactPhoneCanonicalType)) {
return { valid: false, errors: [`phone_numbers.${index}.custom_type: Use the canonical type picker for "${normalizedCustomType}"`] };
}
if (normalizedCustomType) {
if (seenCustomLabels.has(normalizedCustomType)) {
return { valid: false, errors: [`phone_numbers.${index}.custom_type: Duplicate custom phone type labels are not allowed`] };
}
seenCustomLabels.add(normalizedCustomType);
}
const isDefault = Boolean(parsedRow.data.is_default);
if (isDefault) {
defaultCount += 1;
}
normalizedRows.push({
contact_phone_number_id: parsedRow.data.contact_phone_number_id,
phone_number: trimmedPhoneNumber,
canonical_type: canonicalType,
custom_type: customType,
normalized_custom_type: normalizedCustomType,
is_default: isDefault,
display_order: index,
});
}
if (normalizedRows.length > 0 && defaultCount !== 1) {
return {
valid: false,
errors: [defaultCount === 0
? 'Exactly one default phone number is required when phone numbers are present'
: 'Only one phone number can be marked as default'],
};
}
return { valid: true, data: normalizedRows };
}
static validateEmailAddressPayload(
additionalEmailAddresses: ContactEmailAddressInput[] | undefined,
options: { allowUndefined?: boolean; primaryEmail?: string | null } = {}
): ValidationResult {
if (additionalEmailAddresses === undefined) {
return options.allowUndefined ? { valid: true, data: undefined } : { valid: true, data: [] };
}
if (!Array.isArray(additionalEmailAddresses)) {
return { valid: false, errors: ['additional_email_addresses must be an array'] };
}
const normalizedRows: PreparedEmailAddressInput[] = [];
const errors: string[] = [];
const seenEmails = new Set<string>();
const seenCustomLabels = new Set<string>();
const normalizedPrimary = options.primaryEmail ? normalizeEmailAddress(options.primaryEmail) : null;
for (let index = 0; index < additionalEmailAddresses.length; index += 1) {
const row = additionalEmailAddresses[index];
const parsedRow = emailRowInputSchema.safeParse(row);
if (!parsedRow.success) {
errors.push(...parsedRow.error.errors.map(error => `additional_email_addresses.${index}.${error.path.join('.')}: ${error.message}`));
continue;
}
const trimmedEmail = parsedRow.data.email_address.trim();
const normalizedEmail = normalizeEmailAddress(trimmedEmail);
const rowErrors: string[] = [];
if (!emailPattern.test(trimmedEmail)) {
rowErrors.push(`additional_email_addresses.${index}.email_address: Invalid email address`);
}
if (normalizedPrimary && normalizedEmail === normalizedPrimary) {
rowErrors.push(`additional_email_addresses.${index}.email_address: Additional email address cannot match primary email`);
}
if (seenEmails.has(normalizedEmail)) {
rowErrors.push(`additional_email_addresses.${index}.email_address: Duplicate additional email address is not allowed`);
}
const canonicalType = parsedRow.data.canonical_type ?? null;
const customType = parsedRow.data.custom_type?.trim() || null;
if (!canonicalType && !customType) {
rowErrors.push(`additional_email_addresses.${index}: Choose a canonical type or provide a custom type`);
}
if (canonicalType && customType) {
rowErrors.push(`additional_email_addresses.${index}: An additional email row cannot have both canonical and custom types`);
}
const normalizedCustomType = customType ? normalizeCustomTypeLabel(customType) : null;
if (normalizedCustomType && ContactModel.canonicalEmailTypes.includes(normalizedCustomType as ContactEmailCanonicalType)) {
rowErrors.push(`additional_email_addresses.${index}.custom_type: Use the canonical type picker for "${normalizedCustomType}"`);
}
if (normalizedCustomType && seenCustomLabels.has(normalizedCustomType)) {
rowErrors.push(`additional_email_addresses.${index}.custom_type: Duplicate custom email type labels are not allowed`);
}
if (rowErrors.length > 0) {
errors.push(...rowErrors);
continue;
}
seenEmails.add(normalizedEmail);
if (normalizedCustomType) {
seenCustomLabels.add(normalizedCustomType);
}
normalizedRows.push({
contact_additional_email_address_id: parsedRow.data.contact_additional_email_address_id,
email_address: trimmedEmail,
normalized_email_address: normalizedEmail,
canonical_type: canonicalType,
custom_type: customType,
normalized_custom_type: normalizedCustomType,
display_order: index,
});
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, data: normalizedRows };
}
static validatePrimaryEmailTypeInput(
canonicalType: ContactEmailCanonicalType | null | undefined,
customTypeId: string | null | undefined,
customTypeValue: string | null | undefined
): ValidationResult & { data?: PreparedPrimaryEmailTypeInput } {
const trimmedCustomType = customTypeValue?.trim() || null;
if (canonicalType && (customTypeId || trimmedCustomType)) {
return {
valid: false,
errors: ['Choose either a canonical primary email type or a custom primary email type, but not both'],
};
}
if (customTypeId && trimmedCustomType) {
return {
valid: false,
errors: ['Provide either primary_email_custom_type_id or primary_email_custom_type, but not both'],
};
}
const normalizedCustomType = trimmedCustomType ? normalizeCustomTypeLabel(trimmedCustomType) : null;
if (normalizedCustomType && ContactModel.canonicalEmailTypes.includes(normalizedCustomType as ContactEmailCanonicalType)) {
return {
valid: false,
errors: [`primary_email_custom_type: Use the canonical type picker for "${normalizedCustomType}"`],
};
}
return {
valid: true,
data: {
canonicalType: canonicalType ?? null,
customTypeId: customTypeId ?? null,
customType: trimmedCustomType,
normalizedCustomType,
},
};
}
static async checkEmailExists(
email: string,
tenant: string,
trx: Knex.Transaction,
excludeContactId?: string
): Promise<boolean> {
let query = trx('contacts')
.where({ email, tenant });
if (excludeContactId) {
query = query.whereNot('contact_name_id', excludeContactId);
}
const existing = await query.first();
return !!existing;
}
static async createContact(
input: CreateContactInput,
tenant: string,
trx: Knex.Transaction
): Promise<IContact> {
if (!input.full_name?.trim() && !input.email?.trim()) {
throw new Error('VALIDATION_ERROR: Full name and email address are required');
}
if (!input.full_name?.trim()) {
throw new Error('VALIDATION_ERROR: Full name is required');
}
if (!input.email?.trim()) {
throw new Error('VALIDATION_ERROR: Email address is required');
}
const validation = this.validateCreateContactInput(input);
if (!validation.valid) {
throw new Error(`VALIDATION_ERROR: ${validation.errors?.join('; ')}`);
}
const validatedInput = validation.data as CreateContactInput & {
phone_numbers: PreparedPhoneNumberInput[];
additional_email_addresses: PreparedEmailAddressInput[];
primary_email_custom_type?: string | null;
};
const normalizedEmail = input.email.trim().toLowerCase();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
throw new Error('VALIDATION_ERROR: Please enter a valid email address');
}
const existingContact = await trx('contacts')
.where({ email: normalizedEmail, tenant })
.first();
if (existingContact) {
throw new Error('EMAIL_EXISTS: A contact with this email address already exists in the system');
}
if (input.client_id) {
const client = await trx('clients')
.where({ client_id: input.client_id, tenant })
.first();
if (!client) {
throw new Error('FOREIGN_KEY_ERROR: The selected client no longer exists');
}
}
const contactId = uuidv4();
const now = new Date().toISOString();
const primaryEmailCustomTypeId = await this.resolvePrimaryCustomEmailTypeId(validatedInput, tenant, trx, now);
const insertData = {
contact_name_id: contactId,
tenant,
full_name: input.full_name.trim(),
email: normalizedEmail,
primary_email_canonical_type: primaryEmailCustomTypeId
? null
: (validatedInput.primary_email_canonical_type ?? 'work'),
primary_email_custom_type_id: primaryEmailCustomTypeId,
client_id: input.client_id || null,
inbound_ticket_defaults_id: validatedInput.inbound_ticket_defaults_id ?? null,
role: input.role?.trim() || null,
notes: input.notes?.trim() || null,
is_inactive: input.is_inactive || false,
created_at: now,
updated_at: now,
};
try {
await trx('contacts').insert(insertData);
await this.replacePhoneNumbers(contactId, tenant, validatedInput.phone_numbers, trx, now);
await this.replaceAdditionalEmailAddresses(contactId, tenant, validatedInput.additional_email_addresses, trx, now);
const hydratedContact = await this.getContactById(contactId, tenant, trx);
if (!hydratedContact) {
throw new Error('SYSTEM_ERROR: Failed to load contact after creation');
}
return hydratedContact;
} catch (err) {
console.error('Error creating contact:', err);
if (err instanceof Error) {
const message = err.message;
if (
message.includes('VALIDATION_ERROR:') ||
message.includes('EMAIL_EXISTS:') ||
message.includes('FOREIGN_KEY_ERROR:') ||
message.includes('SYSTEM_ERROR:')
) {
throw err;
}
if (message.includes('duplicate key') && message.includes('contacts_email_tenant_unique')) {
throw new Error('EMAIL_EXISTS: A contact with this email address already exists in the system');
}
if (message.includes('A contact email already exists as an additional email address in this tenant')) {
throw new Error('EMAIL_EXISTS: A contact with this email address already exists in the system');
}
if (message.includes('An additional email address already exists as a contact primary email in this tenant')) {
throw new Error('EMAIL_EXISTS: A contact email address already exists in this tenant');
}
if (message.includes('violates not-null constraint')) {
const field = message.match(/column "([^"]+)"/)?.[1] || 'field';
throw new Error(`VALIDATION_ERROR: The ${field} is required`);
}
if (message.includes('violates foreign key constraint') && message.includes('client_id')) {
throw new Error('FOREIGN_KEY_ERROR: The selected client is no longer valid');
}
}
throw new Error('SYSTEM_ERROR: An unexpected error occurred while creating the contact');
}
}
static async updateContact(
contactId: string,
input: UpdateContactInput,
tenant: string,
trx: Knex.Transaction
): Promise<IContact> {
const validation = this.validateUpdateContactInput(input);
if (!validation.valid) {
throw new Error(`VALIDATION_ERROR: ${validation.errors?.join('; ')}`);
}
const updateData = validation.data as Omit<
UpdateContactInput,
'phone_numbers' | 'additional_email_addresses' | 'primary_email_custom_type'
> & {
phone_numbers?: PreparedPhoneNumberInput[];
additional_email_addresses?: PreparedEmailAddressInput[];
primary_email_custom_type?: string | null;
};
const existingContact = await trx('contacts')
.where({ contact_name_id: contactId, tenant })
.first<ContactRecord>();
if (!existingContact) {
throw new Error('NOT_FOUND: Contact not found');
}
let promotedEmailRow: PreparedEmailAddressInput | null = null;
if (updateData.email?.trim()) {
const normalizedEmail = updateData.email.trim().toLowerCase();
if (normalizedEmail !== existingContact.email?.toLowerCase()) {
const incomingAdditionalRows = updateData.additional_email_addresses ?? [];
const normalizedExistingPrimaryEmail = normalizeEmailAddress(existingContact.email || '');
const hasDemotedPrimaryRow = incomingAdditionalRows.some((row) => {
const normalizedRowEmail = row.normalized_email_address ?? normalizeEmailAddress(row.email_address || '');
return normalizedRowEmail === normalizedExistingPrimaryEmail;
});
const hasIncomingPrimaryType =
updateData.primary_email_canonical_type !== undefined ||
updateData.primary_email_custom_type !== undefined ||
updateData.primary_email_custom_type_id !== undefined;
const matchingAdditional = incomingAdditionalRows.find((row) => {
const normalizedRowEmail = row.normalized_email_address ?? normalizeEmailAddress(row.email_address || '');
return normalizedRowEmail === normalizedEmail;
});
if (matchingAdditional) {
promotedEmailRow = matchingAdditional;
} else {
const existingAdditionalRows = await this.getAdditionalEmailAddressesForContact(contactId, tenant, trx);
const existingPromotedRow = existingAdditionalRows.find((row) => row.normalized_email_address === normalizedEmail);
if (existingPromotedRow && hasDemotedPrimaryRow) {
promotedEmailRow = toPreparedEmailAddressInput(existingPromotedRow);
} else if (hasDemotedPrimaryRow && hasIncomingPrimaryType) {
const customType = updateData.primary_email_custom_type?.trim() || null;
promotedEmailRow = {
email_address: updateData.email.trim(),
normalized_email_address: normalizedEmail,
canonical_type: updateData.primary_email_canonical_type ?? null,
custom_type: customType,
normalized_custom_type: customType ? normalizeCustomTypeLabel(customType) : null,
custom_email_type_id: updateData.primary_email_custom_type_id ?? null,
display_order: 0,
};
}
}
if (!promotedEmailRow) {
throw new Error('VALIDATION_ERROR: Changing primary email requires promote an additional email first');
}
}
const emailExists = await this.checkEmailExists(normalizedEmail, tenant, trx, contactId);
if (emailExists) {
throw new Error(`EMAIL_EXISTS: A contact with email ${normalizedEmail} already exists`);
}
updateData.email = normalizedEmail;
if (promotedEmailRow) {
updateData.primary_email_canonical_type = promotedEmailRow.canonical_type ?? null;
updateData.primary_email_custom_type = promotedEmailRow.custom_type ?? null;
updateData.primary_email_custom_type_id = promotedEmailRow.custom_email_type_id ?? null;
const remainingRows = (updateData.additional_email_addresses ?? []).filter((row) => {
const normalizedRowEmail = row.normalized_email_address ?? normalizeEmailAddress(row.email_address || '');
return normalizedRowEmail !== normalizedEmail;
});
const hasDemotedPrimaryRow = remainingRows.some((row) => {
const normalizedRowEmail = row.normalized_email_address ?? normalizeEmailAddress(row.email_address || '');
return normalizedRowEmail === normalizeEmailAddress(existingContact.email || '');
});
if (!hasDemotedPrimaryRow) {
const demotedPrimaryRow: PreparedEmailAddressInput = {
email_address: existingContact.email ?? '',
normalized_email_address: normalizeEmailAddress(existingContact.email || ''),
canonical_type: existingContact.primary_email_canonical_type ?? null,
custom_type: null,
normalized_custom_type: null,
custom_email_type_id: existingContact.primary_email_custom_type_id ?? null,
display_order: remainingRows.length,
};
updateData.additional_email_addresses = [...remainingRows, demotedPrimaryRow];
} else {
updateData.additional_email_addresses = remainingRows.map((row, index) => ({
...row,
display_order: index,
}));
}
}
} else if (updateData.email === null) {
throw new Error('VALIDATION_ERROR: Primary email cannot be removed');
}
if (updateData.client_id) {
const client = await trx('clients')
.where({ client_id: updateData.client_id, tenant })
.first();
if (!client) {
throw new Error('FOREIGN_KEY_ERROR: The selected client is no longer valid');
}
}
const now = new Date().toISOString();
const primaryEmailCustomTypeId = await this.resolvePrimaryCustomEmailTypeId(updateData, tenant, trx, now);
if (
updateData.primary_email_canonical_type !== undefined ||
updateData.primary_email_custom_type !== undefined ||
updateData.primary_email_custom_type_id !== undefined
) {
updateData.primary_email_canonical_type = primaryEmailCustomTypeId
? null
: (updateData.primary_email_canonical_type ?? null);
updateData.primary_email_custom_type_id = primaryEmailCustomTypeId;
}
const shouldClearAdditionalEmailAddressesBeforePrimarySwap =
promotedEmailRow !== null &&
updateData.additional_email_addresses !== undefined;
const dbData: Record<string, unknown> = {
updated_at: now,
};
for (const [key, value] of Object.entries(updateData)) {
if (
key === 'phone_numbers' ||
key === 'additional_email_addresses' ||
key === 'primary_email_custom_type' ||
value === undefined
) continue;
if (typeof value === 'string') {
dbData[key] = value.trim() === '' ? null : value.trim();
} else {
dbData[key] = value;
}
}
if (shouldClearAdditionalEmailAddressesBeforePrimarySwap) {
// During a primary-email promotion, clear the existing additional rows before
// updating contacts.email so immediate uniqueness triggers never see both
// the old and new primary addresses in conflicting locations at once.
await trx('contact_additional_email_addresses')
.where({ tenant, contact_name_id: contactId })
.delete();
}
await trx('contacts')
.where({ contact_name_id: contactId, tenant })
.update(dbData);
if (updateData.phone_numbers !== undefined) {
await this.replacePhoneNumbers(contactId, tenant, updateData.phone_numbers, trx, now);
}
if (updateData.additional_email_addresses !== undefined) {
await this.replaceAdditionalEmailAddresses(contactId, tenant, updateData.additional_email_addresses, trx, now);
}
const hydratedContact = await this.getContactById(contactId, tenant, trx);
if (!hydratedContact) {
throw new Error('SYSTEM_ERROR: Failed to load contact after update');
}
return hydratedContact;
}
static async replacePhoneNumbers(
contactId: string,
tenant: string,
phoneNumbers: PreparedPhoneNumberInput[] | ContactPhoneNumberInput[] | undefined,
trx: Knex.Transaction,
now: string = new Date().toISOString()
): Promise<IContactPhoneNumber[]> {
const validation = this.validatePhoneNumbers(Array.isArray(phoneNumbers) ? phoneNumbers as ContactPhoneNumberInput[] : phoneNumbers);
if (!validation.valid) {
throw new Error(`VALIDATION_ERROR: ${validation.errors?.join('; ')}`);
}
const preparedRows = (validation.data ?? []) as PreparedPhoneNumberInput[];
await trx('contact_phone_numbers')
.where({ tenant, contact_name_id: contactId })
.delete();
if (preparedRows.length === 0) {
return [];
}
const customTypeMap = await this.ensureCustomPhoneTypeDefinitions(preparedRows, tenant, trx, now);
await trx('contact_phone_numbers').insert(
preparedRows.map((row) => ({
tenant,
contact_phone_number_id: row.contact_phone_number_id || uuidv4(),
contact_name_id: contactId,
phone_number: row.phone_number,
canonical_type: row.canonical_type,
custom_phone_type_id: row.normalized_custom_type ? customTypeMap.get(row.normalized_custom_type)?.contact_phone_type_id ?? null : null,
is_default: row.is_default,
display_order: row.display_order,
created_at: now,
updated_at: now,
}))
);
return this.getPhoneNumbersForContact(contactId, tenant, trx);
}
static async replaceAdditionalEmailAddresses(
contactId: string,
tenant: string,
additionalEmailAddresses: PreparedEmailAddressInput[] | ContactEmailAddressInput[] | undefined,
trx: Knex.Transaction,
now: string = new Date().toISOString()
): Promise<IContactEmailAddress[]> {
const preparedRows = this.isPreparedEmailAddressInputArray(additionalEmailAddresses)
? additionalEmailAddresses
: (() => {
const validation = this.validateEmailAddressPayload(
Array.isArray(additionalEmailAddresses)
? additionalEmailAddresses as ContactEmailAddressInput[]
: additionalEmailAddresses,
{ allowUndefined: true }
);
if (!validation.valid) {
throw new Error(`VALIDATION_ERROR: ${validation.errors?.join('; ')}`);
}
return (validation.data ?? []) as PreparedEmailAddressInput[];
})();
await trx('contact_additional_email_addresses')
.where({ tenant, contact_name_id: contactId })
.delete();
if (preparedRows.length === 0) {
return [];
}
const customTypeMap = await this.ensureCustomEmailTypeDefinitions(preparedRows, tenant, trx, now);
await trx('contact_additional_email_addresses').insert(
preparedRows.map((row) => ({
tenant,
contact_additional_email_address_id: row.contact_additional_email_address_id || uuidv4(),
contact_name_id: contactId,
email_address: row.email_address,
canonical_type: row.canonical_type,
custom_email_type_id: row.normalized_custom_type ? customTypeMap.get(row.normalized_custom_type)?.contact_email_type_id ?? null : row.custom_email_type_id ?? null,
display_order: row.display_order,
created_at: now,
updated_at: now,
}))
);
return this.getAdditionalEmailAddressesForContact(contactId, tenant, trx);
}
static async getAdditionalEmailAddressesForContact(
contactId: string,
tenant: string,
trx: Knex.Transaction
): Promise<IContactEmailAddress[]> {
const emailMap = await this.getAdditionalEmailAddressesForContacts([contactId], tenant, trx);
return emailMap.get(contactId) ?? [];
}
static async getAdditionalEmailAddressesForContacts(
contactIds: string[],
tenant: string,
trx: Knex.Transaction
): Promise<Map<string, IContactEmailAddress[]>> {
const emailMap = new Map<string, IContactEmailAddress[]>();
if (contactIds.length === 0) {
return emailMap;
}
const rows = await trx('contact_additional_email_addresses as cea')
.leftJoin('contact_email_type_definitions as cecd', function joinCustomType() {
this.on('cea.custom_email_type_id', '=', 'cecd.contact_email_type_id')
.andOn('cea.tenant', '=', 'cecd.tenant');
})
.select(
'cea.contact_additional_email_address_id',
'cea.contact_name_id',
'cea.email_address',
'cea.normalized_email_address',
'cea.canonical_type',
'cea.custom_email_type_id',
'cea.display_order',
'cea.created_at',
'cea.updated_at',
'cecd.label as custom_type'
)
.where('cea.tenant', tenant)
.whereIn('cea.contact_name_id', contactIds)
.orderBy([{ column: 'cea.contact_name_id', order: 'asc' }, { column: 'cea.display_order', order: 'asc' }]);
for (const row of rows) {
const address: IContactEmailAddress = {
contact_additional_email_address_id: row.contact_additional_email_address_id,
email_address: row.email_address,
normalized_email_address: row.normalized_email_address,
canonical_type: row.canonical_type,
custom_email_type_id: row.custom_email_type_id,
custom_type: row.custom_type,
display_order: row.display_order,
created_at: row.created_at,
updated_at: row.updated_at,
};
const existingRows = emailMap.get(row.contact_name_id) ?? [];
existingRows.push(address);
emailMap.set(row.contact_name_id, existingRows);
}
return emailMap;
}
static async getPhoneNumbersForContact(
contactId: string,
tenant: string,
trx: Knex.Transaction
): Promise<IContactPhoneNumber[]> {
const phoneMap = await this.getPhoneNumbersForContacts([contactId], tenant, trx);
return phoneMap.get(contactId) ?? [];
}
static async getPhoneNumbersForContacts(
contactIds: string[],
tenant: string,
trx: Knex.Transaction
): Promise<Map<string, IContactPhoneNumber[]>> {
const phoneMap = new Map<string, IContactPhoneNumber[]>();
if (contactIds.length === 0) {
return phoneMap;
}
const rows = await trx('contact_phone_numbers as cpn')
.leftJoin('contact_phone_type_definitions as cptd', function joinCustomType() {
this.on('cpn.custom_phone_type_id', '=', 'cptd.contact_phone_type_id')
.andOn('cpn.tenant', '=', 'cptd.tenant');
})
.select(
'cpn.contact_phone_number_id',
'cpn.contact_name_id',
'cpn.phone_number',
'cpn.normalized_phone_number',
'cpn.canonical_type',
'cpn.custom_phone_type_id',
'cpn.is_default',
'cpn.display_order',
'cpn.created_at',
'cpn.updated_at',
'cptd.label as custom_type'
)
.where('cpn.tenant', tenant)
.whereIn('cpn.contact_name_id', contactIds)
.orderBy([{ column: 'cpn.contact_name_id', order: 'asc' }, { column: 'cpn.display_order', order: 'asc' }]);
for (const row of rows) {
const phoneNumber: IContactPhoneNumber = {
contact_phone_number_id: row.contact_phone_number_id,
phone_number: row.phone_number,
normalized_phone_number: row.normalized_phone_number,
canonical_type: row.canonical_type,
custom_phone_type_id: row.custom_phone_type_id,
custom_type: row.custom_type,
is_default: row.is_default,
display_order: row.display_order,
created_at: row.created_at,
updated_at: row.updated_at,
};
const existingRows = phoneMap.get(row.contact_name_id) ?? [];
existingRows.push(phoneNumber);
phoneMap.set(row.contact_name_id, existingRows);
}
return phoneMap;
}
static async hydrateContactsWithPhoneNumbers<T extends ContactRecord>(
contacts: T[],
tenant: string,
trx: Knex.Transaction
): Promise<Array<T & Pick<IContact, 'phone_numbers' | 'default_phone_number' | 'default_phone_type' | 'additional_email_addresses' | 'primary_email_type'>>> {
const phoneMap = await this.getPhoneNumbersForContacts(
contacts.map((contact) => contact.contact_name_id),
tenant,
trx
);
const emailMap = await this.getAdditionalEmailAddressesForContacts(
contacts.map((contact) => contact.contact_name_id),
tenant,
trx
);
const primaryTypeMap = await this.getPrimaryEmailTypeForContacts(contacts, tenant, trx);
return contacts.map((contact) => {
const phoneNumbers = phoneMap.get(contact.contact_name_id) ?? [];
const defaultPhone = phoneNumbers.find((phoneNumber) => phoneNumber.is_default) ?? null;
const additionalEmails = emailMap.get(contact.contact_name_id) ?? [];
const primaryEmailType = derivePrimaryEmailType(
contact.primary_email_canonical_type ?? null,
contact.primary_email_custom_type_id ?? null,
primaryTypeMap
);
return {
...contact,
phone_numbers: phoneNumbers,
default_phone_number: defaultPhone?.phone_number ?? null,
default_phone_type: deriveDefaultPhoneType(defaultPhone ?? undefined),
additional_email_addresses: additionalEmails,
primary_email_type: primaryEmailType,
};
});
}
static async getPrimaryEmailTypeForContacts(
contacts: ContactRecord[],
tenant: string,
trx: Knex.Transaction
): Promise<Map<string, string>> {
const customTypeIds = Array.from(new Set(
contacts
.map((contact) => contact.primary_email_custom_type_id)
.filter((id): id is string => !!id)
));
if (customTypeIds.length === 0) {
return new Map();
}
const rows = await trx('contact_email_type_definitions')
.where({ tenant })
.whereIn('contact_email_type_id', customTypeIds)
.select('contact_email_type_id', 'label');
return new Map(rows.map((row) => [row.contact_email_type_id, row.label]));
}
static async getContactById(
contactId: string,
tenant: string,
trx: Knex.Transaction
): Promise<IContact | null> {
const contact = await trx('contacts')
.where({ contact_name_id: contactId, tenant })
.first<ContactRecord>();
if (!contact) {
return null;
}
const [hydrated] = await this.hydrateContactsWithPhoneNumbers([contact], tenant, trx);
return hydrated as IContact;
}
static async getContactsByClient(
clientId: string,
tenant: string,
trx: Knex.Transaction,
options: { includeInactive?: boolean } = {}
): Promise<IContact[]> {
let query = trx('contacts')
.where({ client_id: clientId, tenant });
if (!options.includeInactive) {
query = query.where(function activeOnly() {
this.where('is_inactive', false).orWhereNull('is_inactive');
});
}
const contacts = await query.orderBy('full_name', 'asc');
return this.hydrateContactsWithPhoneNumbers(contacts as ContactRecord[], tenant, trx) as Promise<IContact[]>;
}
static async getContactByEmail(
email: string,
tenant: string,
trx: Knex.Transaction
): Promise<IContact | null> {
const normalizedEmail = normalizeEmailAddress(email);
const contact = await trx('contacts')
.where({ email: normalizedEmail, tenant })
.first<ContactRecord>();
if (contact) {
const [hydrated] = await this.hydrateContactsWithPhoneNumbers([contact], tenant, trx);
return hydrated as IContact;
}
const additionalEmailMatch = await trx('contact_additional_email_addresses')
.select('contact_name_id')
.where({
tenant,
normalized_email_address: normalizedEmail,
})
.first<{ contact_name_id: string }>();
if (!additionalEmailMatch?.contact_name_id) {
return null;
}
return this.getContactById(additionalEmailMatch.contact_name_id, tenant, trx);
}
static async contactExists(
contactId: string,
tenant: string,
trx: Knex.Transaction
): Promise<boolean> {
const result = await trx('contacts')
.where({ contact_name_id: contactId, tenant })
.count('* as count')
.first();
return parseInt(String(result?.count || 0), 10) > 0;
}
static async upsertContact(
input: CreateContactInput,
tenant: string,
trx: Knex.Transaction
): Promise<IContact> {
if (input.email) {
const existing = await this.getContactByEmail(input.email, tenant, trx);
if (existing) {
return this.updateContact(existing.contact_name_id, input, tenant, trx);
}
}
return this.createContact(input, tenant, trx);
}
static async searchContacts(
searchTerm: string,
tenant: string,
trx: Knex.Transaction,
options: { limit?: number; includeInactive?: boolean } = {}
): Promise<IContact[]> {
let query = trx('contacts as c')
.where('c.tenant', tenant)
.where(function searchByTerm() {
this.where('c.full_name', 'ilike', `%${searchTerm}%`)
.orWhere('c.email', 'ilike', `%${searchTerm}%`)
.orWhereExists(function searchPhones() {
this.select(trx.raw('1'))
.from('contact_phone_numbers as cpn')
.whereRaw('cpn.tenant = c.tenant')
.andWhereRaw('cpn.contact_name_id = c.contact_name_id')
.andWhere(function matchPhone() {
this.where('cpn.phone_number', 'ilike', `%${searchTerm}%`);
const normalizedDigits = normalizePhoneForSearch(searchTerm);
if (normalizedDigits) {
this.orWhere('cpn.normalized_phone_number', 'like', `%${normalizedDigits}%`);
}
});
});
});
if (!options.includeInactive) {
query = query.where(function activeOnly() {
this.where('c.is_inactive', false).orWhereNull('c.is_inactive');
});
}
if (options.limit) {
query = query.limit(options.limit);
}
const contacts = await query.orderBy('c.full_name', 'asc').select('c.*');
return this.hydrateContactsWithPhoneNumbers(contacts as ContactRecord[], tenant, trx) as Promise<IContact[]>;
}
/**
* Get usage count for a custom email label across all contacts in the tenant.
*/
static async getCustomEmailTypeUsageCount(
customTypeLabel: string,
tenant: string,
trx: Knex.Transaction
): Promise<{ label: string; usageCount: number }> {
const normalized = customTypeLabel.trim().replace(/\s+/g, ' ').toLowerCase();
const definition = await trx('contact_email_type_definitions')
.where({ tenant, normalized_label: normalized })
.first<{ contact_email_type_id: string }>();
if (!definition) {
return { label: customTypeLabel, usageCount: 0 };
}
const additionalRows = await trx('contact_additional_email_addresses')
.where({
tenant,
custom_email_type_id: definition.contact_email_type_id,
})
.count<{ count: string }>('* as count')
.first();
const primaryRows = await trx('contacts')
.where({ tenant, primary_email_custom_type_id: definition.contact_email_type_id })
.count<{ count: string }>('* as count')
.first();
const additionalCount = Number(additionalRows?.count ?? 0);
const primaryCount = Number(primaryRows?.count ?? 0);
return { label: customTypeLabel, usageCount: additionalCount + primaryCount };
}
/**
* Find custom email type IDs used by this contact that are not used by any other contact.
*/
static async findLastUsageEmailTypes(
contactId: string,
tenant: string,
trx: Knex.Transaction
): Promise<Array<{ contact_email_type_id: string; label: string }>> {
const contact = await trx('contacts')
.where({ contact_name_id: contactId, tenant })
.first<ContactRecord>();
if (!contact) {
return [];
}
const contactTypeIds = new Set<string>();
if (contact.primary_email_custom_type_id) {
contactTypeIds.add(contact.primary_email_custom_type_id);
}
const additionalTypeIds = await trx('contact_additional_email_addresses')
.where({ tenant, contact_name_id: contactId })
.whereNotNull('custom_email_type_id')
.distinct('custom_email_type_id')
.pluck('custom_email_type_id');
for (const typeId of additionalTypeIds) {
contactTypeIds.add(typeId);
}
if (contactTypeIds.size === 0) return [];
const typeIds = Array.from(contactTypeIds);
const additionalCounts = await trx('contact_additional_email_addresses')
.where({ tenant })
.whereIn('custom_email_type_id', typeIds)
.groupBy('custom_email_type_id')
.select('custom_email_type_id')
.count<Array<{ custom_email_type_id: string; count: string }>>('* as count');
const countByType = new Map<string, number>(
additionalCounts.map((row) => [row.custom_email_type_id, Number(row.count)]),
);
const primaryCounts = await trx('contacts')
.where({ tenant })
.whereNotNull('primary_email_custom_type_id')
.whereIn('primary_email_custom_type_id', typeIds)
.groupBy('primary_email_custom_type_id')
.select('primary_email_custom_type_id')
.count<Array<{ primary_email_custom_type_id: string; count: string }>>('* as count');
for (const row of primaryCounts) {
const prevCount = countByType.get(row.primary_email_custom_type_id) ?? 0;
countByType.set(row.primary_email_custom_type_id, prevCount + Number(row.count));
}
const singleUseTypeIds = Array.from(countByType.entries())
.filter((entry) => entry[1] === 1)
.map((entry) => entry[0]);
if (singleUseTypeIds.length === 0) return [];
return trx('contact_email_type_definitions')
.where({ tenant })
.whereIn('contact_email_type_id', singleUseTypeIds)
.select('contact_email_type_id', 'label');
}
/**
* Find orphaned custom email type definitions not referenced by any contact.
*/
static async findOrphanedEmailTypeDefinitions(
tenant: string,
trx: Knex.Transaction
): Promise<Array<{ contact_email_type_id: string; label: string }>> {
const usedInContacts = await trx('contacts')
.where({ tenant })
.whereNotNull('primary_email_custom_type_id')
.distinct('primary_email_custom_type_id')
.pluck('primary_email_custom_type_id');
const usedInAdditional = await trx('contact_additional_email_addresses')
.where({ tenant })
.whereNotNull('custom_email_type_id')
.distinct('custom_email_type_id')
.pluck('custom_email_type_id');
const usedTypeIds = Array.from(new Set([...usedInContacts, ...usedInAdditional]));
const query = trx('contact_email_type_definitions')
.where({ tenant })
.select('contact_email_type_id', 'label');
if (usedTypeIds.length > 0) {
query.whereNotIn('contact_email_type_id', usedTypeIds);
}
return query;
}
/**
* Delete specific custom email type definitions by ID.
*/
static async deleteEmailTypeDefinitions(
typeIds: string[],
tenant: string,
trx: Knex.Transaction
): Promise<number> {
if (typeIds.length === 0) return 0;
return trx('contact_email_type_definitions')
.where({ tenant })
.whereIn('contact_email_type_id', typeIds)
.delete();
}
/**
* Get usage count for a custom phone type label across all contacts in the tenant.
*/
static async getCustomPhoneTypeUsageCount(
customTypeLabel: string,
tenant: string,
trx: Knex.Transaction
): Promise<{ label: string; usageCount: number }> {
const normalized = customTypeLabel.trim().replace(/\s+/g, ' ').toLowerCase();
const result = await trx('contact_phone_numbers as cpn')
.join('contact_phone_type_definitions as cptd', function joinType() {
this.on('cpn.custom_phone_type_id', '=', 'cptd.contact_phone_type_id')
.andOn('cpn.tenant', '=', 'cptd.tenant');
})
.where('cpn.tenant', tenant)
.where('cptd.normalized_label', normalized)
.count<{ count: string }>('* as count')
.first();
return { label: customTypeLabel, usageCount: Number(result?.count ?? 0) };
}
/**
* Find custom phone types used by a contact that are not used by any other contact.
* These types would become orphaned if the contact is deleted.
*/
static async findLastUsagePhoneTypes(
contactId: string,
tenant: string,
trx: Knex.Transaction
): Promise<Array<{ contact_phone_type_id: string; label: string }>> {
// Get custom type IDs used by this contact
const contactTypeIds = await trx('contact_phone_numbers')
.where({ tenant, contact_name_id: contactId })
.whereNotNull('custom_phone_type_id')
.distinct('custom_phone_type_id')
.pluck('custom_phone_type_id');
if (contactTypeIds.length === 0) return [];
// Find which of those are used ONLY by this contact
const usageCounts = await trx('contact_phone_numbers')
.where('tenant', tenant)
.whereIn('custom_phone_type_id', contactTypeIds)
.groupBy('custom_phone_type_id')
.select('custom_phone_type_id')
.count<Array<{ custom_phone_type_id: string; count: string }>>('* as count');
const singleUseIds = usageCounts
.filter(row => Number(row.count) === 1)
.map(row => row.custom_phone_type_id);
if (singleUseIds.length === 0) return [];
return trx('contact_phone_type_definitions')
.where({ tenant })
.whereIn('contact_phone_type_id', singleUseIds)
.select('contact_phone_type_id', 'label');
}
/**
* Find orphaned custom phone type definitions that are no longer referenced
* by any contact_phone_numbers row in the tenant.
*/
static async findOrphanedPhoneTypeDefinitions(
tenant: string,
trx: Knex.Transaction
): Promise<Array<{ contact_phone_type_id: string; label: string }>> {
return trx('contact_phone_type_definitions as cptd')
.leftJoin('contact_phone_numbers as cpn', function joinPhones() {
this.on('cptd.contact_phone_type_id', '=', 'cpn.custom_phone_type_id')
.andOn('cptd.tenant', '=', 'cpn.tenant');
})
.whereNull('cpn.contact_phone_number_id')
.where('cptd.tenant', tenant)
.select('cptd.contact_phone_type_id', 'cptd.label');
}
/**
* Delete specific custom phone type definitions by ID.
*/
static async deletePhoneTypeDefinitions(
typeIds: string[],
tenant: string,
trx: Knex.Transaction
): Promise<number> {
if (typeIds.length === 0) return 0;
return trx('contact_phone_type_definitions')
.where({ tenant })
.whereIn('contact_phone_type_id', typeIds)
.delete();
}
private static isPreparedEmailAddressInputArray(
rows: PreparedEmailAddressInput[] | ContactEmailAddressInput[] | undefined
): rows is PreparedEmailAddressInput[] {
return Array.isArray(rows) && rows.every((row) => 'normalized_email_address' in row);
}
private static async resolvePrimaryCustomEmailTypeId(
input: Pick<CreateContactInput, 'primary_email_custom_type' | 'primary_email_custom_type_id'>,
tenant: string,
trx: Knex.Transaction,
now: string
): Promise<string | null> {
if (input.primary_email_custom_type_id) {
return input.primary_email_custom_type_id;
}
const customType = input.primary_email_custom_type?.trim();
if (!customType) {
return null;
}
const normalizedCustomType = normalizeCustomTypeLabel(customType);
const customTypeMap = await this.ensureCustomEmailTypeDefinitions(
[{
email_address: '__primary__@example.invalid',
normalized_email_address: '__primary__@example.invalid',
canonical_type: null,
custom_type: customType,
normalized_custom_type: normalizedCustomType,
display_order: 0,
}],
tenant,
trx,
now
);
return customTypeMap.get(normalizedCustomType)?.contact_email_type_id ?? null;
}
private static async ensureCustomPhoneTypeDefinitions(
preparedRows: PreparedPhoneNumberInput[],
tenant: string,
trx: Knex.Transaction,
now: string
): Promise<Map<string, { contact_phone_type_id: string; label: string }>> {
const labelsToEnsure = preparedRows
.filter((row) => row.normalized_custom_type && row.custom_type)
.map((row) => ({
label: row.custom_type as string,
normalized_label: row.normalized_custom_type as string,
}));
if (labelsToEnsure.length === 0) {
return new Map();
}
const uniqueByNormalized = new Map<string, string>();
for (const row of labelsToEnsure) {
if (!uniqueByNormalized.has(row.normalized_label)) {
uniqueByNormalized.set(row.normalized_label, row.label);
}
}
const normalizedLabels = Array.from(uniqueByNormalized.keys());
const existingRows = await trx('contact_phone_type_definitions')
.select('contact_phone_type_id', 'label', 'normalized_label')
.where({ tenant })
.whereIn('normalized_label', normalizedLabels);
const existingByNormalized = new Map(existingRows.map((row) => [row.normalized_label, row]));
const missingRows = normalizedLabels
.filter((normalizedLabel) => !existingByNormalized.has(normalizedLabel))
.map((normalizedLabel) => ({
tenant,
contact_phone_type_id: uuidv4(),
label: uniqueByNormalized.get(normalizedLabel) as string,
normalized_label: normalizedLabel,
created_at: now,
updated_at: now,
}));
if (missingRows.length > 0) {
await trx('contact_phone_type_definitions')
.insert(missingRows)
.onConflict(['tenant', 'normalized_label'])
.ignore();
}
const resolvedRows = await trx('contact_phone_type_definitions')
.select('contact_phone_type_id', 'label', 'normalized_label')
.where({ tenant })
.whereIn('normalized_label', normalizedLabels);
return new Map(
resolvedRows.map((row) => [
row.normalized_label,
{
contact_phone_type_id: row.contact_phone_type_id,
label: row.label,
},
])
);
}
private static async ensureCustomEmailTypeDefinitions(
preparedRows: PreparedEmailAddressInput[],
tenant: string,
trx: Knex.Transaction,
now: string
): Promise<Map<string, { contact_email_type_id: string; label: string }>> {
const labelsToEnsure = preparedRows
.filter((row) => row.normalized_custom_type && row.custom_type)
.map((row) => ({
label: row.custom_type as string,
normalized_label: row.normalized_custom_type as string,
}));
if (labelsToEnsure.length === 0) {
return new Map();
}
const uniqueByNormalized = new Map<string, string>();
for (const row of labelsToEnsure) {
if (!uniqueByNormalized.has(row.normalized_label)) {
uniqueByNormalized.set(row.normalized_label, row.label);
}
}
const normalizedLabels = Array.from(uniqueByNormalized.keys());
const existingRows = await trx('contact_email_type_definitions')
.select('contact_email_type_id', 'label', 'normalized_label')
.where({ tenant })
.whereIn('normalized_label', normalizedLabels);
const existingByNormalized = new Map(existingRows.map((row) => [row.normalized_label, row]));
const missingRows = normalizedLabels
.filter((normalizedLabel) => !existingByNormalized.has(normalizedLabel))
.map((normalizedLabel) => ({
tenant,
contact_email_type_id: uuidv4(),
label: uniqueByNormalized.get(normalizedLabel) as string,
normalized_label: normalizedLabel,
created_at: now,
updated_at: now,
}));
if (missingRows.length > 0) {
await trx('contact_email_type_definitions')
.insert(missingRows)
.onConflict(['tenant', 'normalized_label'])
.ignore();
}
const resolvedRows = await trx('contact_email_type_definitions')
.select('contact_email_type_id', 'label', 'normalized_label')
.where({ tenant })
.whereIn('normalized_label', normalizedLabels);
return new Map(
resolvedRows.map((row) => [
row.normalized_label,
{
contact_email_type_id: row.contact_email_type_id,
label: row.label,
},
])
);
}
}