PSA/shared/lib/tickets/watchList.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

343 lines
8.8 KiB
TypeScript

import { normalizeEmailAddress } from '../email/addressUtils';
export type TicketWatchListSource = 'manual' | 'inbound_to' | 'inbound_cc' | 'inbound_from';
export type TicketWatchListEntityType = 'user' | 'contact';
export interface TicketWatchListEntry {
email: string;
active: boolean;
name?: string;
source?: string;
entity_type?: TicketWatchListEntityType;
entity_id?: string;
created_at?: string;
updated_at?: string;
last_seen_at?: string;
}
export interface TicketWatchListRecipientInput {
email?: string | null;
active?: boolean;
name?: string | null;
source?: string | null;
entity_type?: string | null;
entity_id?: string | null;
created_at?: string | null;
updated_at?: string | null;
last_seen_at?: string | null;
}
interface RecipientLike {
email?: string | null;
name?: string | null;
}
function asOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
function normalizeActive(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return true;
}
return !['false', '0', 'off', 'no'].includes(normalized);
}
return true;
}
function asEntityType(value: unknown): TicketWatchListEntityType | undefined {
const normalized = asOptionalString(value)?.toLowerCase();
if (normalized === 'user' || normalized === 'contact') {
return normalized;
}
return undefined;
}
function mergeEntries(existing: TicketWatchListEntry, incoming: TicketWatchListEntry): TicketWatchListEntry {
return {
...existing,
active: existing.active || incoming.active,
name: existing.name || incoming.name,
source: existing.source || incoming.source,
entity_type: existing.entity_type || incoming.entity_type,
entity_id: existing.entity_id || incoming.entity_id,
created_at: existing.created_at || incoming.created_at,
updated_at: incoming.updated_at || existing.updated_at,
last_seen_at: incoming.last_seen_at || existing.last_seen_at,
};
}
function normalizeEntry(raw: unknown): TicketWatchListEntry | null {
if (typeof raw === 'string') {
const email = normalizeEmailAddress(raw);
if (!email) {
return null;
}
return {
email,
active: true,
};
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return null;
}
const record = raw as Record<string, unknown>;
const email = normalizeEmailAddress(record.email as string | undefined);
if (!email) {
return null;
}
const entry: TicketWatchListEntry = {
email,
active: normalizeActive(record.active),
};
const name = asOptionalString(record.name);
if (name) {
entry.name = name;
}
const source = asOptionalString(record.source);
if (source) {
entry.source = source;
}
const entityType = asEntityType(record.entity_type);
if (entityType) {
entry.entity_type = entityType;
}
const entityId = asOptionalString(record.entity_id);
if (entityId) {
entry.entity_id = entityId;
}
const createdAt = asOptionalString(record.created_at);
if (createdAt) {
entry.created_at = createdAt;
}
const updatedAt = asOptionalString(record.updated_at);
if (updatedAt) {
entry.updated_at = updatedAt;
}
const lastSeenAt = asOptionalString(record.last_seen_at);
if (lastSeenAt) {
entry.last_seen_at = lastSeenAt;
}
return entry;
}
function attributesToObject(attributes: unknown): Record<string, unknown> {
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
return {};
}
return { ...(attributes as Record<string, unknown>) };
}
export function normalizeTicketWatchListEntries(raw: unknown): TicketWatchListEntry[] {
if (!Array.isArray(raw)) {
return [];
}
const deduped = new Map<string, TicketWatchListEntry>();
for (const item of raw) {
const normalized = normalizeEntry(item);
if (!normalized) {
continue;
}
const existing = deduped.get(normalized.email);
if (!existing) {
deduped.set(normalized.email, normalized);
continue;
}
deduped.set(normalized.email, mergeEntries(existing, normalized));
}
return Array.from(deduped.values());
}
export function parseTicketWatchListAttributes(attributes: unknown): TicketWatchListEntry[] {
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
return [];
}
const watchList = (attributes as Record<string, unknown>).watch_list;
return normalizeTicketWatchListEntries(watchList);
}
export function mergeTicketWatchListRecipients(
existingWatchList: unknown,
recipients: TicketWatchListRecipientInput[]
): TicketWatchListEntry[] {
const deduped = new Map<string, TicketWatchListEntry>();
for (const entry of normalizeTicketWatchListEntries(existingWatchList)) {
deduped.set(entry.email, entry);
}
for (const recipient of recipients) {
const email = normalizeEmailAddress(recipient?.email ?? undefined);
if (!email) {
continue;
}
const candidate: TicketWatchListEntry = {
email,
active: recipient.active ?? true,
};
const candidateName = asOptionalString(recipient.name);
if (candidateName) {
candidate.name = candidateName;
}
const candidateSource = asOptionalString(recipient.source);
if (candidateSource) {
candidate.source = candidateSource;
}
const candidateEntityType = asEntityType(recipient.entity_type);
if (candidateEntityType) {
candidate.entity_type = candidateEntityType;
}
const candidateEntityId = asOptionalString(recipient.entity_id);
if (candidateEntityId) {
candidate.entity_id = candidateEntityId;
}
const createdAt = asOptionalString(recipient.created_at);
if (createdAt) {
candidate.created_at = createdAt;
}
const updatedAt = asOptionalString(recipient.updated_at);
if (updatedAt) {
candidate.updated_at = updatedAt;
}
const lastSeenAt = asOptionalString(recipient.last_seen_at);
if (lastSeenAt) {
candidate.last_seen_at = lastSeenAt;
}
const existing = deduped.get(email);
if (!existing) {
deduped.set(email, candidate);
continue;
}
deduped.set(email, mergeEntries(existing, candidate));
}
return Array.from(deduped.values());
}
export function setTicketWatchListOnAttributes(
attributes: unknown,
watchList: unknown
): Record<string, unknown> | null {
const nextAttributes = attributesToObject(attributes);
const normalizedWatchList = normalizeTicketWatchListEntries(watchList);
if (normalizedWatchList.length > 0) {
nextAttributes.watch_list = normalizedWatchList;
} else {
delete nextAttributes.watch_list;
}
if (Object.keys(nextAttributes).length === 0) {
return null;
}
return nextAttributes;
}
export function getActiveWatchListEmails(attributes: unknown): string[] {
return parseTicketWatchListAttributes(attributes)
.filter((entry) => entry.active)
.map((entry) => entry.email);
}
export function buildInboundWatchListRecipients(params: {
to?: RecipientLike[] | null;
cc?: RecipientLike[] | null;
senderEmail?: string | null;
providerMailboxEmail?: string | null;
excludedEmails?: Array<string | null | undefined>;
}): TicketWatchListRecipientInput[] {
const excluded = new Set<string>();
const addExcluded = (email: string | null | undefined) => {
const normalized = normalizeEmailAddress(email);
if (normalized) {
excluded.add(normalized);
}
};
addExcluded(params.senderEmail);
addExcluded(params.providerMailboxEmail);
for (const excludedEmail of params.excludedEmails ?? []) {
addExcluded(excludedEmail);
}
const deduped = new Map<string, TicketWatchListRecipientInput>();
const addRecipient = (recipient: RecipientLike, source: TicketWatchListSource) => {
const email = normalizeEmailAddress(recipient.email);
if (!email || excluded.has(email)) {
return;
}
const existing = deduped.get(email);
const normalizedName = asOptionalString(recipient.name);
if (!existing) {
deduped.set(email, {
email,
active: true,
source,
...(normalizedName ? { name: normalizedName } : {}),
});
return;
}
if (!existing.name && normalizedName) {
existing.name = normalizedName;
}
if (existing.source !== 'inbound_to' && source === 'inbound_to') {
existing.source = source;
}
};
for (const recipient of params.to ?? []) {
addRecipient(recipient, 'inbound_to');
}
for (const recipient of params.cc ?? []) {
addRecipient(recipient, 'inbound_cc');
}
return Array.from(deduped.values());
}