/** * Writes the "phone call made" C_ContactActivity (type PC) onto the called * AD_User once a click-to-dial call has ended. * * Runs DETACHED from any HTTP request (the call may last an hour), so it * never touches an h3 event: everything it needs is captured in the ctx at * dial time, and it authenticates with the iDempiere SERVICE token * (config.api.idempieretoken) rather than the user's session token, which may * have expired meanwhile. Payload mirrors server/utils/offers/contactActivity.ts. * * Fail-soft: logs and returns null on any error; never throws. */ import { string } from 'alga-js' export interface PhoneCallContext { callId: string callerUserId: number callerName: string callerExtension: string orgId: number number: string targetUserId: number | null targetPartnerId: number | null targetLabel: string startedAt: number } export type TargetStatus = 'answered' | 'busy' | 'noanswer' | 'cancel' | 'congestion' | 'chanunavail' | 'unknown' export interface PhoneCallOutcome { agentAnswered: boolean targetStatus: TargetStatus answeredAt: number | null endedAt: number hangupCause: string note: string } const noMs = (ts: number) => new Date(ts).toJSON().replace(/\.\d{3}Z$/, 'Z') const fmtDuration = (ms: number) => { const s = Math.max(0, Math.round(ms / 1000)) const m = Math.floor(s / 60) const r = s % 60 return `${m}:${String(r).padStart(2, '0')} min` } const fmtTime = (ts: number | null) => ts ? new Date(ts).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : '–' /** Common Q.850 cause texts (as Asterisk reports them) → German. */ const CAUSE_DE: Record = { 'Subscriber absent': 'Teilnehmer nicht erreichbar (Handy aus / kein Netz)', 'User busy': 'besetzt', 'No user responding': 'keine Antwort', 'No answer from user': 'keine Antwort', 'Call Rejected': 'Anruf abgewiesen', 'Unallocated (unassigned) number': 'Rufnummer nicht vergeben', 'Normal Clearing': 'normal beendet', 'Normal, unspecified': 'beendet', 'Interworking, unspecified': 'beendet', 'Number changed': 'Rufnummer geändert', 'Destination out of order': 'Ziel nicht erreichbar', 'Channel unavailable': 'Leitung nicht verfügbar', 'Switching equipment congestion': 'Netz überlastet', 'Normal, circuit/channel congestion': 'Netz überlastet', 'Bearer capability not authorized': 'nicht erlaubt' } export const causeText = (raw: string) => CAUSE_DE[String(raw || '').trim()] || String(raw || '').trim() export const describeOutcome = (o: PhoneCallOutcome): { reached: boolean; short: string } => { if (!o.agentAnswered) return { reached: false, short: 'nicht erreicht (Anrufer hat nicht abgenommen)' } switch (o.targetStatus) { case 'answered': return { reached: true, short: `erreicht (${fmtDuration(o.endedAt - (o.answeredAt || o.endedAt))})` } case 'busy': return { reached: false, short: 'nicht erreicht (besetzt)' } case 'noanswer': return { reached: false, short: 'nicht erreicht (keine Antwort)' } case 'cancel': return { reached: false, short: 'nicht erreicht (abgebrochen)' } case 'congestion': return { reached: false, short: 'nicht erreicht (Netz überlastet / nicht vermittelbar)' } case 'chanunavail': return { reached: false, short: `nicht erreicht (${o.hangupCause && CAUSE_DE[o.hangupCause] ? CAUSE_DE[o.hangupCause] : 'Leitung nicht verfügbar'})` } default: return { reached: false, short: 'beendet (Ergebnis unbekannt)' } } } const serviceFetch = async (path: string, method: 'GET' | 'POST', body?: any) => { const config = useRuntimeConfig() const token = (config.api as any)?.idempieretoken if (!token) throw new Error('IDEMPIERETOKEN not configured') return await $fetch(`${config.api.url}/${path}`, { method, headers: { Authorization: 'Bearer ' + token, Accept: 'application/json', 'Content-Type': 'application/json' }, body, retry: 0, timeout: 15000 }) } /** Target user: explicit userId, else the partner's first active contact user. */ const resolveTargetUser = async (ctx: PhoneCallContext): Promise<{ userId: number; orgId: number } | null> => { if (ctx.targetUserId && ctx.targetUserId > 0) { const user = await serviceFetch(`models/ad_user/${ctx.targetUserId}`, 'GET') if (!user?.id) return null return { userId: Number(user.id), orgId: Number(user?.AD_Org_ID?.id || 0) } } if (ctx.targetPartnerId && ctx.targetPartnerId > 0) { const res = await serviceFetch(`models/ad_user?$filter=${string.urlEncode(`C_BPartner_ID eq ${ctx.targetPartnerId} AND IsActive eq true`)}&$orderby=Created asc&$top=1`, 'GET') const u = Array.isArray(res?.records) ? res.records[0] : null if (!u?.id) return null return { userId: Number(u.id), orgId: Number(u?.AD_Org_ID?.id || 0) } } return null } export const createPhoneCallActivity = async (ctx: PhoneCallContext, outcome: PhoneCallOutcome): Promise => { try { const target = await resolveTargetUser(ctx) if (!target) { console.info(`[dial ${ctx.callId}] no target user/partner — activity skipped`) return null } const { short } = describeOutcome(outcome) const who = ctx.targetLabel ? `${ctx.targetLabel} (${ctx.number})` : ctx.number const description = `Anruf an ${who} – ${short}`.substring(0, 250) const lines = [ `Telefonanruf über LogShip (Click-to-Dial)`, `Anrufer: ${ctx.callerName || ctx.callerUserId} (Nebenstelle ${ctx.callerExtension})`, `Gewählte Nummer: ${ctx.number}`, `Gestartet: ${fmtTime(ctx.startedAt)}`, `Anrufer abgenommen: ${outcome.agentAnswered ? 'ja' : 'nein'}`, `Gegenstelle: ${outcome.targetStatus}${outcome.answeredAt ? ` (angenommen ${fmtTime(outcome.answeredAt)})` : ''}`, `Beendet: ${fmtTime(outcome.endedAt)}${outcome.hangupCause ? ` – ${causeText(outcome.hangupCause)}` : ''}`, outcome.note ? `Hinweis: ${outcome.note}` : '' ].filter(Boolean) const orgId = ctx.orgId > 0 ? ctx.orgId : target.orgId const body: any = { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, AD_User_ID: { id: target.userId, tableName: 'AD_User' }, ContactActivityType: { id: 'PC' }, description, comments: lines.join('\n').substring(0, 1990), StartDate: noMs(ctx.startedAt), EndDate: noMs(outcome.endedAt), isComplete: true, tableName: 'c_contactactivity' } if (ctx.callerUserId > 0) body.SalesRep_ID = { id: ctx.callerUserId, tableName: 'AD_User' } const res = await serviceFetch('models/c_contactactivity', 'POST', body) console.info(`[dial ${ctx.callId}] activity ${res?.id || '?'} written on user ${target.userId}: ${description}`) return res?.id ? res : null } catch (err: any) { console.warn(`[dial ${ctx.callId}] activity write failed:`, err?.data?.detail || err?.data?.message || err?.message || err) return null } }