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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
1743 lines
55 KiB
TypeScript
1743 lines
55 KiB
TypeScript
/**
|
|
* Email Workflow Actions for the shared workflow system
|
|
* These actions are used by the email processing workflow and are implemented
|
|
* using shared database patterns to avoid cross-package dependencies.
|
|
*/
|
|
|
|
import { Knex } from 'knex';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { publishWorkflowEvent } from '@alga-psa/event-bus/publishers';
|
|
import { buildInboundEmailReplyReceivedPayload } from '../streams/domainEventBuilders/inboundEmailReplyEventBuilders';
|
|
import { normalizeEmailAddress } from '../../lib/email/addressUtils';
|
|
import { ContactModel } from '../../models/contactModel';
|
|
import {
|
|
mergeTicketWatchListRecipients,
|
|
parseTicketWatchListAttributes,
|
|
setTicketWatchListOnAttributes,
|
|
type TicketWatchListRecipientInput,
|
|
} from '../../lib/tickets/watchList';
|
|
|
|
const COMMENT_RESPONSE_SOURCES = {
|
|
USER: 'user',
|
|
AUTOMATION: 'automation',
|
|
INBOUND_EMAIL: 'inbound_email',
|
|
} as const;
|
|
|
|
const TICKET_ORIGINS = {
|
|
INTERNAL: 'internal',
|
|
CLIENT_PORTAL: 'client_portal',
|
|
INBOUND_EMAIL: 'inbound_email',
|
|
API: 'api',
|
|
} as const;
|
|
|
|
type InboundEmailProviderType = 'google' | 'microsoft' | 'imap';
|
|
|
|
type CommentMetadata = Record<string, unknown> & {
|
|
responseSource?: (typeof COMMENT_RESPONSE_SOURCES)[keyof typeof COMMENT_RESPONSE_SOURCES];
|
|
email?: {
|
|
provider?: InboundEmailProviderType;
|
|
providerType?: InboundEmailProviderType;
|
|
[key: string]: unknown;
|
|
};
|
|
};
|
|
|
|
const TSVECTOR_OVERFLOW_ERROR_FRAGMENT = 'string is too long for tsvector';
|
|
const DATA_IMAGE_BASE64_PATTERN = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\r\n]+/gi;
|
|
const OVERSIZED_WORD_PATTERN = /\b\w{200,}\b/g;
|
|
const FALLBACK_INDEX_SAFE_COMMENT_MAX_CHARS = 500_000;
|
|
const EMPTY_FALLBACK_COMMENT =
|
|
'[Inbound email content trimmed due to indexing limits. See attachments for full message content.]';
|
|
|
|
function buildDefaultPhoneNumbers(phone?: string) {
|
|
const trimmedPhone = phone?.trim();
|
|
if (!trimmedPhone) {
|
|
return [];
|
|
}
|
|
|
|
return [{
|
|
phone_number: trimmedPhone,
|
|
canonical_type: 'work' as const,
|
|
is_default: true,
|
|
display_order: 0,
|
|
}];
|
|
}
|
|
|
|
function getDefaultPhoneNumber(contact: {
|
|
default_phone_number?: string | null;
|
|
phone_numbers: Array<{ is_default: boolean; phone_number: string }>;
|
|
}): string | undefined {
|
|
return contact.default_phone_number
|
|
|| contact.phone_numbers.find((phoneNumber) => phoneNumber.is_default)?.phone_number;
|
|
}
|
|
|
|
function isTsvectorOverflowError(error: unknown): boolean {
|
|
const message =
|
|
typeof error === 'object' && error && 'message' in error
|
|
? String((error as { message?: unknown }).message ?? '')
|
|
: '';
|
|
return message.toLowerCase().includes(TSVECTOR_OVERFLOW_ERROR_FRAGMENT);
|
|
}
|
|
|
|
function sanitizeCommentContentForIndexRetry(content: string): string {
|
|
const withoutDataImages = content.replace(DATA_IMAGE_BASE64_PATTERN, '[inline-image]');
|
|
const withoutOversizedWords = withoutDataImages.replace(OVERSIZED_WORD_PATTERN, '');
|
|
const condensed = withoutOversizedWords.replace(/\s+/g, ' ').trim();
|
|
const truncated = condensed.slice(0, FALLBACK_INDEX_SAFE_COMMENT_MAX_CHARS).trim();
|
|
return truncated.length > 0 ? truncated : EMPTY_FALLBACK_COMMENT;
|
|
}
|
|
|
|
// =============================================================================
|
|
// INTERFACES
|
|
// =============================================================================
|
|
|
|
export interface FindContactByEmailOutput {
|
|
contact_id: string;
|
|
name: string;
|
|
email: string;
|
|
matched_email?: string;
|
|
client_id: string;
|
|
user_id?: string;
|
|
user_type?: 'internal' | 'client';
|
|
client_name: string;
|
|
phone?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface FindContactByEmailContext {
|
|
ticketId?: string;
|
|
ticketClientId?: string | null;
|
|
ticketContactId?: string | null;
|
|
defaultClientId?: string | null;
|
|
}
|
|
|
|
export interface CreateOrFindContactInput {
|
|
email: string;
|
|
name?: string;
|
|
client_id: string;
|
|
phone?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface CreateOrFindContactOutput {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
client_id: string;
|
|
phone?: string;
|
|
title?: string;
|
|
created_at: string;
|
|
is_new: boolean;
|
|
}
|
|
|
|
export interface FindTicketByEmailThreadInput {
|
|
threadId?: string;
|
|
inReplyTo?: string;
|
|
references?: string[];
|
|
originalMessageId?: string;
|
|
}
|
|
|
|
export interface FindTicketByEmailThreadOutput {
|
|
ticketId: string;
|
|
ticketNumber: string;
|
|
subject: string;
|
|
status: string;
|
|
originalEmailId: string;
|
|
threadInfo: {
|
|
threadId?: string;
|
|
originalMessageId?: string;
|
|
};
|
|
}
|
|
|
|
export interface ProcessEmailAttachmentInput {
|
|
emailId: string;
|
|
attachmentId: string;
|
|
ticketId: string;
|
|
tenant: string;
|
|
providerId: string;
|
|
attachmentData: {
|
|
id: string;
|
|
name: string;
|
|
contentType: string;
|
|
size: number;
|
|
contentId?: string;
|
|
};
|
|
}
|
|
|
|
export interface ProcessEmailAttachmentOutput {
|
|
documentId: string;
|
|
success: boolean;
|
|
fileName: string;
|
|
fileSize: number;
|
|
contentType: string;
|
|
}
|
|
|
|
export interface SaveEmailClientAssociationInput {
|
|
email: string;
|
|
client_id: string;
|
|
contact_id?: string;
|
|
confidence_score?: number;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface SaveEmailClientAssociationOutput {
|
|
success: boolean;
|
|
associationId: string;
|
|
email: string;
|
|
client_id: string;
|
|
}
|
|
|
|
function parseTicketAttributes(raw: unknown): Record<string, unknown> {
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
|
|
if (typeof raw === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
return { ...(raw as Record<string, unknown>) };
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
export type InboundDestinationResolutionSource =
|
|
| 'contact_override'
|
|
| 'client_default_from_contact'
|
|
| 'client_default_from_domain'
|
|
| 'provider_default';
|
|
|
|
export interface EffectiveInboundTicketDefaultsInput {
|
|
tenant: string;
|
|
providerId: string;
|
|
providerDefaults: any | null;
|
|
matchedContactId?: string | null;
|
|
matchedContactClientId?: string | null;
|
|
domainMatchedClientId?: string | null;
|
|
}
|
|
|
|
export interface EffectiveInboundTicketDefaultsResult {
|
|
defaults: any | null;
|
|
source: InboundDestinationResolutionSource | null;
|
|
fallbackReason?: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMAIL CONTACT ACTIONS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Find contact by email address
|
|
*/
|
|
export async function findContactByEmail(
|
|
email: string,
|
|
tenant: string,
|
|
context: FindContactByEmailContext = {}
|
|
): Promise<FindContactByEmailOutput | null> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
const normalizedEmail = normalizeEmailAddress(email);
|
|
|
|
if (!normalizedEmail) {
|
|
return null;
|
|
}
|
|
|
|
const contact = await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const internalUser = await trx('users')
|
|
.select(
|
|
'user_id',
|
|
'first_name',
|
|
'last_name',
|
|
'email'
|
|
)
|
|
.where({ tenant, user_type: 'internal' })
|
|
.andWhereRaw('lower(email) = ?', [normalizedEmail])
|
|
.orderBy('created_at', 'asc')
|
|
.first();
|
|
|
|
if (internalUser) {
|
|
const displayName = `${internalUser.first_name || ''} ${internalUser.last_name || ''}`.trim();
|
|
return {
|
|
contact_id: '',
|
|
name: displayName || normalizedEmail,
|
|
email: normalizeEmailAddress(internalUser.email) ?? normalizedEmail,
|
|
matched_email: normalizedEmail,
|
|
client_id: '',
|
|
user_id: internalUser.user_id,
|
|
user_type: 'internal' as const,
|
|
client_name: '',
|
|
};
|
|
}
|
|
|
|
const candidates = await trx('contacts')
|
|
.select(
|
|
'contacts.contact_name_id',
|
|
'contacts.contact_name_id as contact_id',
|
|
'contacts.full_name as name',
|
|
'contacts.email',
|
|
'contacts.client_id',
|
|
trx('users')
|
|
.select('users.user_id')
|
|
.whereRaw('users.contact_id = contacts.contact_name_id')
|
|
.andWhere('users.tenant', tenant)
|
|
.andWhere('users.user_type', 'client')
|
|
.orderBy('users.created_at', 'asc')
|
|
.limit(1)
|
|
.as('user_id'),
|
|
'clients.client_name',
|
|
'contacts.role as title'
|
|
)
|
|
.leftJoin('clients', function() {
|
|
this.on('contacts.client_id', 'clients.client_id')
|
|
.andOn('clients.tenant', 'contacts.tenant');
|
|
})
|
|
.where({
|
|
'contacts.tenant': tenant
|
|
})
|
|
.andWhere(function contactEmailMatch(this: Knex.QueryBuilder) {
|
|
this
|
|
.where('contacts.email', normalizedEmail)
|
|
.orWhereExists(function additionalEmailMatch() {
|
|
this.select(trx.raw('1'))
|
|
.from('contact_additional_email_addresses as caea')
|
|
.whereRaw('caea.contact_name_id = contacts.contact_name_id')
|
|
.andWhere('caea.tenant', tenant)
|
|
.andWhere('caea.normalized_email_address', normalizedEmail);
|
|
});
|
|
})
|
|
.orderBy('contacts.created_at', 'asc')
|
|
.orderBy('contacts.contact_name_id', 'asc');
|
|
|
|
if (!candidates.length) {
|
|
return null;
|
|
}
|
|
|
|
const hydratedCandidates = await ContactModel.hydrateContactsWithPhoneNumbers(candidates as any[], tenant, trx);
|
|
const candidatesById = new Map(
|
|
hydratedCandidates.map((candidate: any) => [candidate.contact_name_id, candidate])
|
|
);
|
|
|
|
const normalizeCandidate = (candidate: any): FindContactByEmailOutput => {
|
|
const hydrated = candidatesById.get(candidate.contact_id) ?? candidate;
|
|
return {
|
|
...candidate,
|
|
matched_email: normalizedEmail,
|
|
phone: getDefaultPhoneNumber(hydrated),
|
|
user_id: candidate?.user_id ?? undefined,
|
|
user_type: candidate?.user_id ? 'client' : undefined,
|
|
};
|
|
};
|
|
|
|
let ticketClientId = context.ticketClientId ?? null;
|
|
let ticketContactId = context.ticketContactId ?? null;
|
|
|
|
if ((context.ticketId && !ticketClientId) || (context.ticketId && !ticketContactId)) {
|
|
const ticket = await trx('tickets')
|
|
.select('client_id', 'contact_name_id')
|
|
.where({
|
|
tenant,
|
|
ticket_id: context.ticketId,
|
|
})
|
|
.first<{ client_id?: string | null; contact_name_id?: string | null }>();
|
|
|
|
if (ticket) {
|
|
ticketClientId = ticketClientId ?? ticket.client_id ?? null;
|
|
ticketContactId = ticketContactId ?? ticket.contact_name_id ?? null;
|
|
}
|
|
}
|
|
|
|
if (ticketContactId) {
|
|
const directTicketContact = candidates.find((candidate: any) => candidate.contact_id === ticketContactId);
|
|
if (directTicketContact) {
|
|
return normalizeCandidate(directTicketContact);
|
|
}
|
|
}
|
|
|
|
if (ticketClientId) {
|
|
const inTicketClient = candidates.filter((candidate: any) => candidate.client_id === ticketClientId);
|
|
if (inTicketClient.length === 1) {
|
|
return normalizeCandidate(inTicketClient[0]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (context.defaultClientId) {
|
|
const inDefaultClient = candidates.filter((candidate: any) => candidate.client_id === context.defaultClientId);
|
|
if (inDefaultClient.length === 1) {
|
|
return normalizeCandidate(inDefaultClient[0]);
|
|
}
|
|
if (inDefaultClient.length > 1) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (candidates.length === 1) {
|
|
return normalizeCandidate(candidates[0]);
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
return contact || null;
|
|
}
|
|
|
|
/**
|
|
* Find a client_id for an explicitly configured inbound email domain.
|
|
*
|
|
* Returns null when:
|
|
* - the domain is blank/invalid
|
|
* - no mapping exists for the domain in the tenant
|
|
*/
|
|
export async function findClientIdByInboundEmailDomain(
|
|
domain: string,
|
|
tenant: string
|
|
): Promise<string | null> {
|
|
const normalizedDomain = (domain ?? '').trim().toLowerCase();
|
|
if (!normalizedDomain) {
|
|
return null;
|
|
}
|
|
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
try {
|
|
const row = await trx('client_inbound_email_domains')
|
|
.select('client_id')
|
|
.where('tenant', tenant)
|
|
.andWhereRaw('lower(domain) = ?', [normalizedDomain])
|
|
.first();
|
|
|
|
const clientId = (row as any)?.client_id;
|
|
return typeof clientId === 'string' && clientId ? clientId : null;
|
|
} catch (error: any) {
|
|
// Best-effort safety: if the mapping table isn't present in a given environment,
|
|
// do not break inbound email processing; treat as "no match".
|
|
const message = error?.message ? String(error.message) : '';
|
|
if (message.includes('client_inbound_email_domains') || message.includes('does not exist')) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read a client's configured "primary_contact_id" (stored in clients.properties)
|
|
* and validate it's a currently-active contact belonging to the client.
|
|
*
|
|
* Returns null when:
|
|
* - client doesn't exist
|
|
* - properties.primary_contact_id is unset/invalid
|
|
* - the referenced contact doesn't exist, doesn't belong to the client, or is inactive
|
|
*/
|
|
export async function findValidClientPrimaryContactId(
|
|
clientId: string,
|
|
tenant: string
|
|
): Promise<string | null> {
|
|
if (!clientId) return null;
|
|
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const clientRow = await trx('clients')
|
|
.select('properties')
|
|
.where({ tenant, client_id: clientId })
|
|
.first();
|
|
|
|
if (!clientRow) {
|
|
return null;
|
|
}
|
|
|
|
const properties = (clientRow as any)?.properties;
|
|
const primaryContactId =
|
|
properties && typeof properties === 'object'
|
|
? (properties as any).primary_contact_id
|
|
: undefined;
|
|
|
|
if (typeof primaryContactId !== 'string' || !primaryContactId) {
|
|
return null;
|
|
}
|
|
|
|
const contactRow = await trx('contacts')
|
|
.select('contact_name_id')
|
|
.where({
|
|
tenant,
|
|
client_id: clientId,
|
|
contact_name_id: primaryContactId,
|
|
is_inactive: false,
|
|
})
|
|
.first();
|
|
|
|
const validatedId = (contactRow as any)?.contact_name_id;
|
|
return typeof validatedId === 'string' && validatedId ? validatedId : null;
|
|
});
|
|
}
|
|
|
|
const INBOUND_DEFAULTS_SELECT_COLUMNS = [
|
|
'board_id',
|
|
'status_id',
|
|
'priority_id',
|
|
'client_id',
|
|
'entered_by',
|
|
'category_id',
|
|
'subcategory_id',
|
|
'location_id',
|
|
] as const;
|
|
|
|
async function getActiveInboundTicketDefaultsById(
|
|
trx: Knex.Transaction,
|
|
tenant: string,
|
|
defaultsId: string
|
|
): Promise<any | null> {
|
|
if (!defaultsId) return null;
|
|
return trx('inbound_ticket_defaults')
|
|
.where({ tenant, id: defaultsId, is_active: true })
|
|
.select(...INBOUND_DEFAULTS_SELECT_COLUMNS)
|
|
.first();
|
|
}
|
|
|
|
async function getContactInboundDestinationConfig(
|
|
trx: Knex.Transaction,
|
|
tenant: string,
|
|
contactId: string
|
|
): Promise<{ inbound_ticket_defaults_id: string | null; client_id: string | null } | null> {
|
|
try {
|
|
const row = await trx('contacts')
|
|
.select('inbound_ticket_defaults_id', 'client_id')
|
|
.where({ tenant, contact_name_id: contactId })
|
|
.first();
|
|
|
|
if (!row) return null;
|
|
return {
|
|
inbound_ticket_defaults_id: (row as any).inbound_ticket_defaults_id ?? null,
|
|
client_id: (row as any).client_id ?? null,
|
|
};
|
|
} catch (error: any) {
|
|
const message = String(error?.message ?? '');
|
|
if (message.includes('inbound_ticket_defaults_id') && message.includes('contacts')) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getClientInboundDestinationDefaultsId(
|
|
trx: Knex.Transaction,
|
|
tenant: string,
|
|
clientId: string
|
|
): Promise<string | null> {
|
|
if (!clientId) return null;
|
|
try {
|
|
const row = await trx('clients')
|
|
.select('inbound_ticket_defaults_id')
|
|
.where({ tenant, client_id: clientId })
|
|
.first();
|
|
|
|
return (row as any)?.inbound_ticket_defaults_id ?? null;
|
|
} catch (error: any) {
|
|
const message = String(error?.message ?? '');
|
|
if (message.includes('inbound_ticket_defaults_id') && message.includes('clients')) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function resolveEffectiveInboundTicketDefaults(
|
|
input: EffectiveInboundTicketDefaultsInput
|
|
): Promise<EffectiveInboundTicketDefaultsResult> {
|
|
if (!input.providerDefaults) {
|
|
return { defaults: null, source: null };
|
|
}
|
|
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
return withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
let fallbackReason: string | undefined;
|
|
|
|
const logBase = {
|
|
tenant: input.tenant,
|
|
providerId: input.providerId,
|
|
matchedContactId: input.matchedContactId ?? null,
|
|
matchedContactClientId: input.matchedContactClientId ?? null,
|
|
domainMatchedClientId: input.domainMatchedClientId ?? null,
|
|
};
|
|
|
|
if (input.matchedContactId) {
|
|
const contactConfig = await getContactInboundDestinationConfig(
|
|
trx,
|
|
input.tenant,
|
|
input.matchedContactId
|
|
);
|
|
|
|
const contactOverrideDefaultsId = contactConfig?.inbound_ticket_defaults_id ?? null;
|
|
if (contactOverrideDefaultsId) {
|
|
const contactOverrideDefaults = await getActiveInboundTicketDefaultsById(
|
|
trx,
|
|
input.tenant,
|
|
contactOverrideDefaultsId
|
|
);
|
|
if (contactOverrideDefaults) {
|
|
console.debug('resolveEffectiveInboundTicketDefaults: resolved destination', {
|
|
...logBase,
|
|
source: 'contact_override',
|
|
});
|
|
return {
|
|
defaults: contactOverrideDefaults,
|
|
source: 'contact_override',
|
|
};
|
|
}
|
|
|
|
fallbackReason = 'invalid_or_inactive_contact_override';
|
|
console.warn('resolveEffectiveInboundTicketDefaults: invalid contact override destination; using fallback', {
|
|
...logBase,
|
|
source: 'contact_override',
|
|
configuredDefaultsId: contactOverrideDefaultsId,
|
|
fallback: 'provider_default',
|
|
});
|
|
}
|
|
|
|
const contactClientId = contactConfig?.client_id ?? input.matchedContactClientId ?? null;
|
|
if (contactClientId) {
|
|
const clientDefaultsId = await getClientInboundDestinationDefaultsId(
|
|
trx,
|
|
input.tenant,
|
|
contactClientId
|
|
);
|
|
if (clientDefaultsId) {
|
|
const clientDefaults = await getActiveInboundTicketDefaultsById(
|
|
trx,
|
|
input.tenant,
|
|
clientDefaultsId
|
|
);
|
|
|
|
if (clientDefaults) {
|
|
console.debug('resolveEffectiveInboundTicketDefaults: resolved destination', {
|
|
...logBase,
|
|
source: 'client_default_from_contact',
|
|
resolvedClientId: contactClientId,
|
|
});
|
|
return {
|
|
defaults: clientDefaults,
|
|
source: 'client_default_from_contact',
|
|
};
|
|
}
|
|
|
|
fallbackReason = fallbackReason ?? 'invalid_or_inactive_client_default_from_contact';
|
|
console.warn('resolveEffectiveInboundTicketDefaults: invalid client default destination; using fallback', {
|
|
...logBase,
|
|
source: 'client_default_from_contact',
|
|
resolvedClientId: contactClientId,
|
|
configuredDefaultsId: clientDefaultsId,
|
|
fallback: 'provider_default',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (input.domainMatchedClientId) {
|
|
const domainClientDefaultsId = await getClientInboundDestinationDefaultsId(
|
|
trx,
|
|
input.tenant,
|
|
input.domainMatchedClientId
|
|
);
|
|
if (domainClientDefaultsId) {
|
|
const domainClientDefaults = await getActiveInboundTicketDefaultsById(
|
|
trx,
|
|
input.tenant,
|
|
domainClientDefaultsId
|
|
);
|
|
|
|
if (domainClientDefaults) {
|
|
console.debug('resolveEffectiveInboundTicketDefaults: resolved destination', {
|
|
...logBase,
|
|
source: 'client_default_from_domain',
|
|
resolvedClientId: input.domainMatchedClientId,
|
|
});
|
|
return {
|
|
defaults: domainClientDefaults,
|
|
source: 'client_default_from_domain',
|
|
};
|
|
}
|
|
|
|
fallbackReason = fallbackReason ?? 'invalid_or_inactive_client_default_from_domain';
|
|
console.warn('resolveEffectiveInboundTicketDefaults: invalid domain client default destination; using fallback', {
|
|
...logBase,
|
|
source: 'client_default_from_domain',
|
|
resolvedClientId: input.domainMatchedClientId,
|
|
configuredDefaultsId: domainClientDefaultsId,
|
|
fallback: 'provider_default',
|
|
});
|
|
}
|
|
}
|
|
|
|
console.debug('resolveEffectiveInboundTicketDefaults: resolved destination', {
|
|
...logBase,
|
|
source: 'provider_default',
|
|
fallbackReason: fallbackReason ?? null,
|
|
});
|
|
return {
|
|
defaults: input.providerDefaults,
|
|
source: 'provider_default',
|
|
fallbackReason,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create or find contact by email and client
|
|
*/
|
|
export async function createOrFindContact(
|
|
input: CreateOrFindContactInput,
|
|
tenant: string
|
|
): Promise<CreateOrFindContactOutput> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
const normalizedEmail = normalizeEmailAddress(input.email);
|
|
|
|
if (!normalizedEmail) {
|
|
throw new Error('Invalid email address');
|
|
}
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
// First try to find existing contact
|
|
const existingContact = await ContactModel.getContactByEmail(normalizedEmail, tenant, trx);
|
|
|
|
if (existingContact && existingContact.client_id === input.client_id) {
|
|
return {
|
|
id: existingContact.contact_name_id,
|
|
name: existingContact.full_name,
|
|
email: existingContact.email || normalizedEmail,
|
|
client_id: existingContact.client_id,
|
|
phone: getDefaultPhoneNumber(existingContact),
|
|
title: existingContact.role || undefined,
|
|
created_at: existingContact.created_at ? new Date(existingContact.created_at).toISOString() : new Date().toISOString(),
|
|
is_new: false
|
|
};
|
|
}
|
|
|
|
const createdContact = await ContactModel.createContact({
|
|
full_name: input.name || normalizedEmail,
|
|
email: normalizedEmail,
|
|
client_id: input.client_id,
|
|
phone_numbers: buildDefaultPhoneNumbers(input.phone),
|
|
role: input.title,
|
|
}, tenant, trx);
|
|
|
|
return {
|
|
id: createdContact.contact_name_id,
|
|
name: createdContact.full_name,
|
|
email: createdContact.email || normalizedEmail,
|
|
client_id: createdContact.client_id || input.client_id,
|
|
phone: getDefaultPhoneNumber(createdContact),
|
|
title: createdContact.role || input.title,
|
|
created_at: createdContact.created_at ? new Date(createdContact.created_at).toISOString() : new Date().toISOString(),
|
|
is_new: true
|
|
};
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMAIL TICKET THREADING ACTIONS
|
|
// =============================================================================
|
|
|
|
function normalizeThreadLookupValue(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed === '<' || trimmed === '>' || trimmed === '<>') return null;
|
|
return trimmed;
|
|
}
|
|
|
|
function normalizeThreadLookupList(value: unknown): string[] {
|
|
const entries: string[] = Array.isArray(value)
|
|
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
: typeof value === 'string'
|
|
? [value]
|
|
: [];
|
|
|
|
const normalized = new Set<string>();
|
|
for (const entry of entries) {
|
|
const trimmed = entry.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const matches = trimmed.match(/<[^<>]+>/g);
|
|
if (matches?.length) {
|
|
for (const match of matches) {
|
|
const cleaned = normalizeThreadLookupValue(match);
|
|
if (cleaned) normalized.add(cleaned);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const cleaned = normalizeThreadLookupValue(trimmed);
|
|
if (cleaned) normalized.add(cleaned);
|
|
}
|
|
|
|
return Array.from(normalized);
|
|
}
|
|
|
|
/**
|
|
* Find existing ticket by email thread information
|
|
*/
|
|
export async function findTicketByEmailThread(
|
|
input: FindTicketByEmailThreadInput,
|
|
tenant: string
|
|
): Promise<FindTicketByEmailThreadOutput | null> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const threadId = normalizeThreadLookupValue(input.threadId);
|
|
const inReplyTo = normalizeThreadLookupValue(input.inReplyTo);
|
|
const references = normalizeThreadLookupList((input as any).references);
|
|
const originalMessageId = normalizeThreadLookupValue(input.originalMessageId);
|
|
|
|
// Strategy 1: Search by thread ID if available
|
|
if (threadId) {
|
|
const ticket = await findTicketByThreadId(trx, tenant, threadId);
|
|
if (ticket) return ticket;
|
|
}
|
|
|
|
// Strategy 2: Search by In-Reply-To header (most reliable)
|
|
if (inReplyTo) {
|
|
const ticket = await findTicketByOriginalMessageId(trx, tenant, inReplyTo);
|
|
if (ticket) return ticket;
|
|
}
|
|
|
|
// Strategy 3: Search by References headers
|
|
if (references.length > 0) {
|
|
for (const messageId of references) {
|
|
const ticket = await findTicketByOriginalMessageId(trx, tenant, messageId);
|
|
if (ticket) return ticket;
|
|
}
|
|
}
|
|
|
|
// Strategy 4: Search by original message ID directly
|
|
if (originalMessageId) {
|
|
const ticket = await findTicketByOriginalMessageId(trx, tenant, originalMessageId);
|
|
if (ticket) return ticket;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find ticket by thread ID
|
|
*/
|
|
async function findTicketByThreadId(
|
|
trx: Knex.Transaction,
|
|
tenant: string,
|
|
threadId: string
|
|
): Promise<FindTicketByEmailThreadOutput | null> {
|
|
const ticket = await trx('tickets as t')
|
|
.leftJoin('statuses as s', function() {
|
|
this.on('t.status_id', 's.status_id')
|
|
.andOn('t.tenant', 's.tenant');
|
|
})
|
|
.select(
|
|
't.ticket_id as ticketId',
|
|
't.ticket_number as ticketNumber',
|
|
't.title as subject',
|
|
's.name as status',
|
|
't.email_metadata'
|
|
)
|
|
.where('t.tenant', tenant)
|
|
.where(function() {
|
|
this.whereRaw("t.email_metadata->>'threadId' = ?", [threadId])
|
|
.orWhereRaw("t.email_metadata->'threadInfo'->>'threadId' = ?", [threadId]);
|
|
})
|
|
.first();
|
|
|
|
if (!ticket) return null;
|
|
|
|
const emailMetadata = ticket.email_metadata || {};
|
|
|
|
return {
|
|
ticketId: ticket.ticketId,
|
|
ticketNumber: ticket.ticketNumber,
|
|
subject: ticket.subject,
|
|
status: ticket.status || 'Unknown',
|
|
originalEmailId: emailMetadata.messageId || emailMetadata.originalEmailId || '',
|
|
threadInfo: {
|
|
threadId: emailMetadata.threadId || threadId,
|
|
originalMessageId: emailMetadata.messageId
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find ticket by original message ID from email metadata
|
|
*/
|
|
async function findTicketByOriginalMessageId(
|
|
trx: Knex.Transaction,
|
|
tenant: string,
|
|
messageId: string
|
|
): Promise<FindTicketByEmailThreadOutput | null> {
|
|
const ticket = await trx('tickets as t')
|
|
.leftJoin('statuses as s', function() {
|
|
this.on('t.status_id', 's.status_id')
|
|
.andOn('t.tenant', 's.tenant');
|
|
})
|
|
.select(
|
|
't.ticket_id as ticketId',
|
|
't.ticket_number as ticketNumber',
|
|
't.title as subject',
|
|
's.name as status',
|
|
't.email_metadata'
|
|
)
|
|
.where('t.tenant', tenant)
|
|
.where(function() {
|
|
this.whereRaw("t.email_metadata->>'messageId' = ?", [messageId])
|
|
.orWhereRaw("t.email_metadata->>'inReplyTo' = ?", [messageId])
|
|
.orWhereRaw("t.email_metadata->'references' \\? ?", [messageId]);
|
|
})
|
|
.first();
|
|
|
|
if (!ticket) return null;
|
|
|
|
const emailMetadata = ticket.email_metadata || {};
|
|
|
|
return {
|
|
ticketId: ticket.ticketId,
|
|
ticketNumber: ticket.ticketNumber,
|
|
subject: ticket.subject,
|
|
status: ticket.status || 'Unknown',
|
|
originalEmailId: emailMetadata.messageId || messageId,
|
|
threadInfo: {
|
|
threadId: emailMetadata.threadId,
|
|
originalMessageId: emailMetadata.messageId || messageId
|
|
}
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMAIL ATTACHMENT ACTIONS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Process email attachment and associate with ticket
|
|
*/
|
|
export async function processEmailAttachment(
|
|
input: ProcessEmailAttachmentInput,
|
|
tenant: string
|
|
): Promise<ProcessEmailAttachmentOutput> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const documentId = uuidv4();
|
|
const now = new Date();
|
|
|
|
// Create document record for the attachment
|
|
await trx('documents').insert({
|
|
document_id: documentId,
|
|
tenant,
|
|
name: input.attachmentData.name,
|
|
file_size: input.attachmentData.size,
|
|
content_type: input.attachmentData.contentType,
|
|
source: 'email_attachment',
|
|
metadata: JSON.stringify({
|
|
emailId: input.emailId,
|
|
attachmentId: input.attachmentId,
|
|
providerId: input.providerId,
|
|
contentId: input.attachmentData.contentId
|
|
}),
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
|
|
// Associate document with ticket
|
|
await trx('document_associations').insert({
|
|
document_id: documentId,
|
|
entity_type: 'ticket',
|
|
entity_id: input.ticketId,
|
|
tenant,
|
|
created_at: now
|
|
});
|
|
|
|
return {
|
|
documentId,
|
|
success: true,
|
|
fileName: input.attachmentData.name,
|
|
fileSize: input.attachmentData.size,
|
|
contentType: input.attachmentData.contentType
|
|
};
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMAIL CLIENT ASSOCIATION ACTIONS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Save email-to-client association
|
|
*/
|
|
export async function saveEmailClientAssociation(
|
|
input: SaveEmailClientAssociationInput,
|
|
tenant: string
|
|
): Promise<SaveEmailClientAssociationOutput> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
const normalizedEmail = normalizeEmailAddress(input.email);
|
|
|
|
if (!normalizedEmail) {
|
|
throw new Error('Invalid email address');
|
|
}
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const associationId = uuidv4();
|
|
const now = new Date();
|
|
|
|
// Check if association already exists
|
|
const existing = await trx('email_client_associations')
|
|
.where('tenant', tenant)
|
|
.whereRaw('LOWER(email) = LOWER(?)', [normalizedEmail])
|
|
.where('client_id', input.client_id)
|
|
.first();
|
|
|
|
if (existing) {
|
|
// Update existing association
|
|
await trx('email_client_associations')
|
|
.where('id', existing.id)
|
|
.andWhere('tenant', tenant)
|
|
.update({
|
|
contact_id: input.contact_id,
|
|
confidence_score: input.confidence_score || 1.0,
|
|
notes: input.notes,
|
|
updated_at: now
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
associationId: existing.id,
|
|
email: normalizedEmail,
|
|
client_id: input.client_id
|
|
};
|
|
} else {
|
|
// Create new association
|
|
await trx('email_client_associations').insert({
|
|
id: associationId,
|
|
tenant,
|
|
email: normalizedEmail,
|
|
client_id: input.client_id,
|
|
contact_id: input.contact_id,
|
|
confidence_score: input.confidence_score || 1.0,
|
|
notes: input.notes,
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
associationId,
|
|
email: normalizedEmail,
|
|
client_id: input.client_id
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMAIL WORKFLOW WRAPPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Resolve default inbound ticket settings for a tenant
|
|
*/
|
|
export async function resolveInboundTicketDefaults(
|
|
tenant: string,
|
|
providerId?: string
|
|
): Promise<any> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
// Require provider-specific defaults; no tenant-level fallback
|
|
let defaults: any | null = null;
|
|
|
|
if (!providerId) {
|
|
console.warn('resolveInboundTicketDefaults: providerId is required but missing');
|
|
return null;
|
|
}
|
|
|
|
const provider = await trx('email_providers')
|
|
.select('id', 'tenant', 'inbound_ticket_defaults_id')
|
|
.where({ id: providerId, tenant })
|
|
.first();
|
|
|
|
if (!provider) {
|
|
console.warn(`resolveInboundTicketDefaults: provider ${providerId} not found in tenant ${tenant}`);
|
|
return null;
|
|
}
|
|
if (!provider.inbound_ticket_defaults_id) {
|
|
console.warn(`resolveInboundTicketDefaults: provider ${providerId} has no inbound_ticket_defaults_id set (tenant ${tenant})`);
|
|
return null;
|
|
}
|
|
|
|
defaults = await trx('inbound_ticket_defaults')
|
|
.where({ tenant, id: provider.inbound_ticket_defaults_id, is_active: true })
|
|
.select(...INBOUND_DEFAULTS_SELECT_COLUMNS)
|
|
.first();
|
|
|
|
if (!defaults) {
|
|
console.warn(`resolveInboundTicketDefaults: defaults not found or inactive for id ${provider.inbound_ticket_defaults_id} (tenant ${tenant}). Attempting tenant-level fallback.`);
|
|
const fallback = await trx('inbound_ticket_defaults')
|
|
.where({ tenant, is_active: true })
|
|
.orderBy('updated_at', 'desc')
|
|
.select(...INBOUND_DEFAULTS_SELECT_COLUMNS)
|
|
.first();
|
|
if (!fallback) {
|
|
console.warn(`resolveInboundTicketDefaults: no active tenant-level defaults found for tenant ${tenant}`);
|
|
return null;
|
|
}
|
|
defaults = fallback;
|
|
}
|
|
|
|
console.log(`Retrieved inbound ticket defaults:`, defaults);
|
|
// Return the flat defaults structure
|
|
return defaults;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use resolveInboundTicketDefaults instead
|
|
* Resolve email provider's inbound ticket defaults
|
|
*/
|
|
export async function resolveEmailProviderDefaults(
|
|
providerId: string,
|
|
tenant: string
|
|
): Promise<any> {
|
|
console.warn('resolveEmailProviderDefaults is deprecated, use resolveInboundTicketDefaults instead');
|
|
return await resolveInboundTicketDefaults(tenant);
|
|
}
|
|
|
|
/**
|
|
* Create ticket from email data - Enhanced with events and analytics
|
|
*/
|
|
export async function createTicketFromEmail(
|
|
ticketData: {
|
|
title: string;
|
|
description: string;
|
|
client_id?: string;
|
|
contact_id?: string;
|
|
source?: string;
|
|
board_id?: string;
|
|
status_id?: string;
|
|
priority_id?: string;
|
|
category_id?: string;
|
|
subcategory_id?: string;
|
|
location_id?: string;
|
|
entered_by?: string | null;
|
|
assigned_to?: string;
|
|
email_metadata?: any;
|
|
attributes?: Record<string, unknown> | null;
|
|
},
|
|
tenant: string,
|
|
userId?: string
|
|
): Promise<{ ticket_id: string; ticket_number: string }> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
const { TicketModel } = await import('@alga-psa/shared/models/ticketModel');
|
|
const { WorkflowEventPublisher } = await import('../adapters/workflowEventPublisher');
|
|
const { WorkflowAnalyticsTracker } = await import('../adapters/workflowAnalyticsTracker');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
// Create adapters for workflow context
|
|
const eventPublisher = new WorkflowEventPublisher();
|
|
const analyticsTracker = new WorkflowAnalyticsTracker();
|
|
|
|
// Determine assigned_to: use provided value or fall back to board's default
|
|
let assignedTo = ticketData.assigned_to;
|
|
if (!assignedTo && ticketData.board_id) {
|
|
const board = await trx('boards')
|
|
.select('default_assigned_to')
|
|
.where({ board_id: ticketData.board_id, tenant })
|
|
.first();
|
|
if (board?.default_assigned_to) {
|
|
assignedTo = board.default_assigned_to;
|
|
}
|
|
}
|
|
|
|
// Use enhanced TicketModel with events and analytics
|
|
const result = await TicketModel.createTicketWithRetry({
|
|
title: ticketData.title,
|
|
description: ticketData.description,
|
|
client_id: ticketData.client_id,
|
|
contact_id: ticketData.contact_id,
|
|
source: ticketData.source || 'email',
|
|
board_id: ticketData.board_id,
|
|
status_id: ticketData.status_id,
|
|
priority_id: ticketData.priority_id,
|
|
category_id: ticketData.category_id,
|
|
subcategory_id: ticketData.subcategory_id,
|
|
location_id: ticketData.location_id,
|
|
entered_by: ticketData.entered_by || undefined,
|
|
assigned_to: assignedTo,
|
|
email_metadata: ticketData.email_metadata,
|
|
attributes: ticketData.attributes ?? undefined,
|
|
ticket_origin: TICKET_ORIGINS.INBOUND_EMAIL,
|
|
}, tenant, trx, {}, eventPublisher, analyticsTracker, userId, 3);
|
|
|
|
// Publish TICKET_ASSIGNED event if an agent was assigned
|
|
// Note: Event publishing failure should not prevent ticket creation
|
|
if (assignedTo) {
|
|
try {
|
|
await eventPublisher.publishTicketAssigned({
|
|
tenantId: tenant,
|
|
ticketId: result.ticket_id,
|
|
userId: assignedTo,
|
|
assignedByUserId: userId || ticketData.entered_by || undefined
|
|
});
|
|
} catch (eventError) {
|
|
console.error('Failed to publish TICKET_ASSIGNED event:', eventError);
|
|
// Continue - ticket was created successfully, event can be retried or logged
|
|
}
|
|
}
|
|
|
|
// Write activity-timeline entry for the inbound-email ticket create.
|
|
// This runs inside the admin transaction, so we MUST pass explicit
|
|
// tenant — the helper does not depend on the `app.current_tenant`
|
|
// GUC (which is unset in admin transactions).
|
|
try {
|
|
const { writeTicketActivity, TICKET_ACTIVITY_EVENT, TICKET_ACTIVITY_ENTITY, TICKET_ACTIVITY_ACTOR, TICKET_ACTIVITY_SOURCE } =
|
|
await import('../../lib/ticketActivity/index');
|
|
const safeEmailMeta = ticketData.email_metadata
|
|
? {
|
|
messageId: (ticketData.email_metadata as any)?.messageId ?? null,
|
|
threadId: (ticketData.email_metadata as any)?.threadId ?? null,
|
|
from: (ticketData.email_metadata as any)?.from ?? null,
|
|
subject: (ticketData.email_metadata as any)?.subject ?? null,
|
|
provider: (ticketData.email_metadata as any)?.provider ?? null,
|
|
receivedAt: (ticketData.email_metadata as any)?.receivedAt ?? null,
|
|
appliedRuleId: (ticketData.email_metadata as any)?.appliedRuleId ?? null,
|
|
appliedRuleName: (ticketData.email_metadata as any)?.appliedRuleName ?? null,
|
|
clientMatchSource: (ticketData.email_metadata as any)?.clientMatchSource ?? null,
|
|
}
|
|
: undefined;
|
|
|
|
await writeTicketActivity(trx, {
|
|
tenant,
|
|
ticketId: result.ticket_id,
|
|
eventType: TICKET_ACTIVITY_EVENT.CREATED,
|
|
entityType: TICKET_ACTIVITY_ENTITY.TICKET,
|
|
entityId: result.ticket_id,
|
|
actor: ticketData.contact_id
|
|
? {
|
|
actorType: TICKET_ACTIVITY_ACTOR.EMAIL_SENDER,
|
|
contactId: ticketData.contact_id,
|
|
userId: userId ?? null,
|
|
}
|
|
: {
|
|
actorType: TICKET_ACTIVITY_ACTOR.SYSTEM,
|
|
userId: userId ?? null,
|
|
},
|
|
source: TICKET_ACTIVITY_SOURCE.INBOUND_EMAIL,
|
|
details: {
|
|
title: ticketData.title,
|
|
board_id: ticketData.board_id ?? null,
|
|
status_id: ticketData.status_id ?? null,
|
|
priority_id: ticketData.priority_id ?? null,
|
|
assigned_to: assignedTo ?? null,
|
|
client_id: ticketData.client_id ?? null,
|
|
ticket_origin: TICKET_ORIGINS.INBOUND_EMAIL,
|
|
email: safeEmailMeta,
|
|
},
|
|
});
|
|
|
|
// Distinct activity row for the inbound email itself; useful for
|
|
// the UI to surface a separate "Received inbound email" entry
|
|
// independent of the ticket-create line.
|
|
await writeTicketActivity(trx, {
|
|
tenant,
|
|
ticketId: result.ticket_id,
|
|
eventType: TICKET_ACTIVITY_EVENT.INBOUND_EMAIL_RECEIVED,
|
|
entityType: TICKET_ACTIVITY_ENTITY.EMAIL,
|
|
entityId: safeEmailMeta?.messageId ?? null,
|
|
actor: ticketData.contact_id
|
|
? {
|
|
actorType: TICKET_ACTIVITY_ACTOR.EMAIL_SENDER,
|
|
contactId: ticketData.contact_id,
|
|
}
|
|
: { actorType: TICKET_ACTIVITY_ACTOR.SYSTEM },
|
|
source: TICKET_ACTIVITY_SOURCE.INBOUND_EMAIL,
|
|
details: { email: safeEmailMeta },
|
|
});
|
|
} catch (activityError) {
|
|
// Activity write failure inside the admin transaction must fail
|
|
// fast (PRD NFR-03) so the inbound-email ticket creation is
|
|
// rolled back rather than persisted without a timeline entry.
|
|
throw activityError;
|
|
}
|
|
|
|
return {
|
|
ticket_id: result.ticket_id,
|
|
ticket_number: result.ticket_number
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function findEmailProviderMailboxAddress(
|
|
providerId: string,
|
|
tenant: string
|
|
): Promise<string | null> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const provider = await trx('email_providers')
|
|
.select('mailbox')
|
|
.where({ id: providerId, tenant })
|
|
.first<{ mailbox?: string | null }>();
|
|
|
|
return normalizeEmailAddress(provider?.mailbox ?? undefined);
|
|
});
|
|
}
|
|
|
|
export async function upsertTicketWatchListRecipients(
|
|
params: {
|
|
ticketId: string;
|
|
recipients: TicketWatchListRecipientInput[];
|
|
},
|
|
tenant: string
|
|
): Promise<{ updated: boolean; watchList: ReturnType<typeof parseTicketWatchListAttributes> }> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const ticket = await trx('tickets')
|
|
.select('attributes')
|
|
.where({
|
|
ticket_id: params.ticketId,
|
|
tenant,
|
|
})
|
|
.first<{ attributes?: unknown }>();
|
|
|
|
if (!ticket) {
|
|
return { updated: false, watchList: [] };
|
|
}
|
|
|
|
const currentAttributes = parseTicketAttributes(ticket.attributes);
|
|
const currentWatchList = parseTicketWatchListAttributes(currentAttributes);
|
|
const mergedWatchList = mergeTicketWatchListRecipients(currentWatchList, params.recipients ?? []);
|
|
|
|
if (JSON.stringify(currentWatchList) === JSON.stringify(mergedWatchList)) {
|
|
return { updated: false, watchList: currentWatchList };
|
|
}
|
|
|
|
const nextAttributes = setTicketWatchListOnAttributes(currentAttributes, mergedWatchList);
|
|
await trx('tickets')
|
|
.where({
|
|
ticket_id: params.ticketId,
|
|
tenant,
|
|
})
|
|
.update({
|
|
attributes: nextAttributes ? JSON.stringify(nextAttributes) : null,
|
|
updated_at: new Date(),
|
|
});
|
|
|
|
return { updated: true, watchList: mergedWatchList };
|
|
});
|
|
}
|
|
|
|
const INBOUND_PROVIDER_TYPES: ReadonlySet<InboundEmailProviderType> = new Set([
|
|
'google',
|
|
'microsoft',
|
|
'imap',
|
|
]);
|
|
|
|
export function normalizeInboundEmailProvider(
|
|
provider: string | undefined
|
|
): InboundEmailProviderType | undefined {
|
|
if (!provider) {
|
|
return undefined;
|
|
}
|
|
|
|
return INBOUND_PROVIDER_TYPES.has(provider as InboundEmailProviderType)
|
|
? (provider as InboundEmailProviderType)
|
|
: undefined;
|
|
}
|
|
|
|
export function buildInboundEmailCommentMetadata(
|
|
metadata: unknown,
|
|
inboundReplyEvent?: { provider: string }
|
|
): CommentMetadata {
|
|
const baseMetadata: Record<string, unknown> =
|
|
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
|
|
? { ...(metadata as Record<string, unknown>) }
|
|
: {};
|
|
|
|
const emailMetadata: Record<string, unknown> =
|
|
baseMetadata.email && typeof baseMetadata.email === 'object' && !Array.isArray(baseMetadata.email)
|
|
? { ...(baseMetadata.email as Record<string, unknown>) }
|
|
: {};
|
|
|
|
const providerType =
|
|
normalizeInboundEmailProvider(inboundReplyEvent?.provider) ??
|
|
normalizeInboundEmailProvider(
|
|
typeof emailMetadata.provider === 'string' ? emailMetadata.provider : undefined
|
|
) ??
|
|
normalizeInboundEmailProvider(
|
|
typeof emailMetadata.providerType === 'string' ? emailMetadata.providerType : undefined
|
|
);
|
|
|
|
if (providerType) {
|
|
emailMetadata.provider = providerType;
|
|
emailMetadata.providerType = providerType;
|
|
}
|
|
|
|
return {
|
|
...baseMetadata,
|
|
responseSource: COMMENT_RESPONSE_SOURCES.INBOUND_EMAIL,
|
|
...(Object.keys(emailMetadata).length ? { email: emailMetadata } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create comment from email data - Enhanced with events and analytics
|
|
*/
|
|
export async function createCommentFromEmail(
|
|
commentData: {
|
|
ticket_id: string;
|
|
content: string;
|
|
parent_comment_id?: string;
|
|
format?: string;
|
|
source?: string;
|
|
author_type?: string;
|
|
author_id?: string;
|
|
contact_id?: string;
|
|
metadata?: any;
|
|
// Keep this comment in-app only (no tech email). Set for the first comment on a new
|
|
// inbound-email ticket, which the TICKET_CREATED email already covers.
|
|
suppressTechEmailNotification?: boolean;
|
|
inboundReplyEvent?: {
|
|
messageId: string;
|
|
threadId?: string;
|
|
from: string;
|
|
to: string[];
|
|
subject?: string;
|
|
receivedAt?: string;
|
|
provider: string;
|
|
matchedBy: string;
|
|
};
|
|
},
|
|
tenant: string,
|
|
userId?: string
|
|
): Promise<string> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
const { TicketModel } = await import('@alga-psa/shared/models/ticketModel');
|
|
const { WorkflowEventPublisher } = await import('../adapters/workflowEventPublisher');
|
|
const { WorkflowAnalyticsTracker } = await import('../adapters/workflowAnalyticsTracker');
|
|
|
|
const normalizedAuthorType: 'internal' | 'client' | 'unknown' = (() => {
|
|
switch (commentData.author_type) {
|
|
case 'contact':
|
|
case 'client':
|
|
return 'client';
|
|
case 'internal':
|
|
case 'system':
|
|
return 'internal';
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
})();
|
|
|
|
const ticketModelAuthorType: 'internal' | 'contact' | 'system' =
|
|
normalizedAuthorType === 'client'
|
|
? 'contact'
|
|
: normalizedAuthorType === 'internal'
|
|
? 'internal'
|
|
: 'system';
|
|
|
|
const createCommentInTransaction = async (content: string): Promise<string> =>
|
|
withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
// Create adapters for workflow context
|
|
const eventPublisher = new WorkflowEventPublisher({
|
|
suppressCommentEmail: commentData.suppressTechEmailNotification ?? false,
|
|
});
|
|
const analyticsTracker = new WorkflowAnalyticsTracker();
|
|
|
|
// Use enhanced TicketModel with events and analytics
|
|
const result = await TicketModel.createComment({
|
|
ticket_id: commentData.ticket_id,
|
|
content,
|
|
parent_comment_id: commentData.parent_comment_id,
|
|
is_internal: false,
|
|
is_resolution: false,
|
|
author_type: ticketModelAuthorType,
|
|
author_id: commentData.author_id,
|
|
contact_id: commentData.contact_id,
|
|
metadata: buildInboundEmailCommentMetadata(
|
|
commentData.metadata,
|
|
commentData.inboundReplyEvent
|
|
? {
|
|
provider: commentData.inboundReplyEvent.provider,
|
|
}
|
|
: undefined
|
|
)
|
|
}, tenant, trx, eventPublisher, analyticsTracker, userId);
|
|
|
|
// Only update response state if tracking is enabled for this tenant
|
|
const tenantSettingsRow = await trx('tenant_settings')
|
|
.select('ticket_display_settings')
|
|
.where({ tenant })
|
|
.first();
|
|
const responseStateEnabled = (tenantSettingsRow?.ticket_display_settings as any)?.responseStateTrackingEnabled ?? true;
|
|
|
|
if (responseStateEnabled) {
|
|
if (normalizedAuthorType === 'client') {
|
|
await trx('tickets')
|
|
.where({ ticket_id: commentData.ticket_id, tenant })
|
|
.update({ response_state: 'awaiting_internal' });
|
|
} else if (normalizedAuthorType === 'internal') {
|
|
await trx('tickets')
|
|
.where({ ticket_id: commentData.ticket_id, tenant })
|
|
.update({ response_state: 'awaiting_client' });
|
|
}
|
|
}
|
|
|
|
// Activity row for the inbound-email comment. Source is always
|
|
// inbound_email here; actor classification depends on whether we
|
|
// resolved the sender to a contact.
|
|
const { writeTicketActivity, TICKET_ACTIVITY_EVENT, TICKET_ACTIVITY_ENTITY, TICKET_ACTIVITY_ACTOR, TICKET_ACTIVITY_SOURCE } =
|
|
await import('../../lib/ticketActivity/index');
|
|
|
|
const safeEmail = commentData.inboundReplyEvent
|
|
? {
|
|
messageId: commentData.inboundReplyEvent.messageId,
|
|
threadId: commentData.inboundReplyEvent.threadId ?? null,
|
|
from: commentData.inboundReplyEvent.from,
|
|
subject: commentData.inboundReplyEvent.subject ?? null,
|
|
provider: commentData.inboundReplyEvent.provider,
|
|
matchedBy: commentData.inboundReplyEvent.matchedBy,
|
|
receivedAt: commentData.inboundReplyEvent.receivedAt ?? null,
|
|
}
|
|
: null;
|
|
|
|
await writeTicketActivity(trx, {
|
|
tenant,
|
|
ticketId: commentData.ticket_id,
|
|
eventType:
|
|
normalizedAuthorType === 'client'
|
|
? TICKET_ACTIVITY_EVENT.CUSTOMER_REPLIED
|
|
: TICKET_ACTIVITY_EVENT.COMMENT_ADDED,
|
|
entityType: TICKET_ACTIVITY_ENTITY.COMMENT,
|
|
entityId: result.comment_id,
|
|
actor: commentData.contact_id
|
|
? {
|
|
actorType: TICKET_ACTIVITY_ACTOR.EMAIL_SENDER,
|
|
contactId: commentData.contact_id,
|
|
userId: userId ?? null,
|
|
}
|
|
: {
|
|
actorType: TICKET_ACTIVITY_ACTOR.SYSTEM,
|
|
userId: userId ?? null,
|
|
},
|
|
source: TICKET_ACTIVITY_SOURCE.INBOUND_EMAIL,
|
|
details: {
|
|
author_type: normalizedAuthorType,
|
|
// NEVER store full email body content here — see PRD FR-38.
|
|
email: safeEmail,
|
|
},
|
|
});
|
|
|
|
return result.comment_id;
|
|
});
|
|
|
|
let commentId: string;
|
|
try {
|
|
commentId = await createCommentInTransaction(commentData.content);
|
|
} catch (error) {
|
|
if (!isTsvectorOverflowError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
const sanitizedContent = sanitizeCommentContentForIndexRetry(commentData.content);
|
|
console.warn('createCommentFromEmail: tsvector overflow during comment insert; retrying with sanitized body', {
|
|
ticketId: commentData.ticket_id,
|
|
tenant,
|
|
originalLength: commentData.content.length,
|
|
sanitizedLength: sanitizedContent.length,
|
|
});
|
|
|
|
try {
|
|
commentId = await createCommentInTransaction(sanitizedContent);
|
|
} catch (retryError) {
|
|
if (!isTsvectorOverflowError(retryError)) {
|
|
throw retryError;
|
|
}
|
|
|
|
console.warn(
|
|
'createCommentFromEmail: sanitized retry still overflowed; persisting minimal fallback comment body',
|
|
{
|
|
ticketId: commentData.ticket_id,
|
|
tenant,
|
|
}
|
|
);
|
|
commentId = await createCommentInTransaction(EMPTY_FALLBACK_COMMENT);
|
|
}
|
|
}
|
|
|
|
if (commentData.inboundReplyEvent) {
|
|
try {
|
|
const threadId = commentData.inboundReplyEvent.threadId || commentData.inboundReplyEvent.messageId;
|
|
const to = commentData.inboundReplyEvent.to?.length
|
|
? commentData.inboundReplyEvent.to
|
|
: [commentData.inboundReplyEvent.from];
|
|
|
|
await publishWorkflowEvent({
|
|
eventType: 'INBOUND_EMAIL_REPLY_RECEIVED',
|
|
payload: buildInboundEmailReplyReceivedPayload({
|
|
messageId: commentData.inboundReplyEvent.messageId,
|
|
threadId,
|
|
ticketId: commentData.ticket_id,
|
|
from: commentData.inboundReplyEvent.from,
|
|
to,
|
|
subject: commentData.inboundReplyEvent.subject,
|
|
receivedAt: commentData.inboundReplyEvent.receivedAt,
|
|
provider: commentData.inboundReplyEvent.provider,
|
|
matchedBy: commentData.inboundReplyEvent.matchedBy,
|
|
}),
|
|
ctx: {
|
|
tenantId: tenant,
|
|
occurredAt: commentData.inboundReplyEvent.receivedAt ?? new Date(),
|
|
},
|
|
idempotencyKey: `inbound-email-reply:${tenant}:${commentData.ticket_id}:${commentData.inboundReplyEvent.messageId}`,
|
|
});
|
|
} catch (eventError) {
|
|
console.warn('Failed to publish INBOUND_EMAIL_REPLY_RECEIVED event:', eventError);
|
|
}
|
|
}
|
|
|
|
return commentId;
|
|
}
|
|
|
|
export async function parseEmailReplyBody(
|
|
body: {
|
|
text?: string;
|
|
html?: string;
|
|
},
|
|
config?: Record<string, any>
|
|
): Promise<any> {
|
|
const module = await import('../../lib/email/replyParser');
|
|
const parseEmailReply = module.parseEmailReply as (input: { text: string; html?: string }, cfg?: Record<string, any>) => any;
|
|
return parseEmailReply({
|
|
text: body?.text || '',
|
|
html: body?.html || undefined,
|
|
}, config);
|
|
}
|
|
|
|
export async function findTicketByReplyToken(
|
|
token: string,
|
|
tenant: string
|
|
): Promise<{ ticketId?: string; commentId?: string; projectId?: string } | null> {
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const record = await trx('email_reply_tokens')
|
|
.where({ tenant, token })
|
|
.first();
|
|
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
ticketId: record.ticket_id || undefined,
|
|
commentId: record.comment_id || undefined,
|
|
projectId: record.project_id || undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create client from email data
|
|
*/
|
|
export async function createClientFromEmail(
|
|
clientData: {
|
|
client_name: string;
|
|
email?: string;
|
|
source?: string;
|
|
},
|
|
tenant: string
|
|
): Promise<{ client_id: string; client_name: string }> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const clientId = uuidv4();
|
|
|
|
await trx('clients')
|
|
.insert({
|
|
client_id: clientId,
|
|
tenant,
|
|
client_name: clientData.client_name,
|
|
email: clientData.email,
|
|
source: clientData.source || 'email',
|
|
created_at: new Date(),
|
|
updated_at: new Date()
|
|
});
|
|
|
|
return {
|
|
client_id: clientId,
|
|
client_name: clientData.client_name
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get client by ID
|
|
*/
|
|
export async function getClientByIdForEmail(
|
|
clientId: string,
|
|
tenant: string
|
|
): Promise<{ client_id: string; client_name: string } | null> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const client = await trx('clients')
|
|
.select('client_id', 'client_name')
|
|
.where({ client_id: clientId, tenant })
|
|
.first();
|
|
|
|
return client || null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create board from email data
|
|
*/
|
|
export async function createBoardFromEmail(
|
|
boardData: {
|
|
board_name: string;
|
|
description?: string;
|
|
is_default?: boolean;
|
|
},
|
|
tenant: string
|
|
): Promise<{ board_id: string; board_name: string }> {
|
|
const { withAdminTransaction } = await import('@alga-psa/db');
|
|
|
|
return await withAdminTransaction(async (trx: Knex.Transaction) => {
|
|
const boardId = uuidv4();
|
|
|
|
await trx('boards')
|
|
.insert({
|
|
board_id: boardId,
|
|
tenant,
|
|
board_name: boardData.board_name,
|
|
description: boardData.description || '',
|
|
is_default: boardData.is_default || false,
|
|
is_inactive: false,
|
|
created_at: new Date(),
|
|
updated_at: new Date()
|
|
});
|
|
|
|
return {
|
|
board_id: boardId,
|
|
board_name: boardData.board_name
|
|
};
|
|
});
|
|
}
|