/** * Automatic C_ContactActivity entries for offer/contract interactions. * * Every customer-facing step of the sales flow leaves a trace on the record's * activity timeline (the same `c_contactactivity` rows the lead page's manual * "Aktivität" modal creates — see server/api/admin/users/[id]/activity): * - quote sent (EM) quote/send.post.ts * - contract sent (EM) contract/send.post.ts * - contract signed (EM) contractSigning.ts → finalizeSignature() * - quote confirmed (EM) quoteConfirmation.ts → finalizeQuoteConfirmation() * * Documents are referenced INSIDE `Comments` in a machine-readable block the * timeline turns into buttons (see app/components/admin/LeadDetailRecordActivity.vue): * * …free text… * * [Dokumente] * | <url> * <title> | <url> * * `Description` (255 chars in iDempiere, mandatory) carries the short headline. * `Comments` holds the details + the document block. * * ALWAYS fail-soft: an activity must never break the send/sign/confirm flow. * Callers wrap in try/catch anyway; this helper also never throws by itself * except when awaited inside a try (it returns null on any failure). */ import { string } from 'alga-js' import fetchHelper from '../fetchHelper' export const ACTIVITY_DOCS_MARKER = '[Dokumente]' export type ActivityType = 'EM' | 'ME' | 'PC' | 'TA' export interface ActivityDocumentRef { title: string url: string } export interface ContactActivityInput { source: 'lead' | 'partner' recordId: number type?: ActivityType description: string comments?: string documents?: ActivityDocumentRef[] /** Optional override; otherwise resolved from cookie → record. */ organizationId?: number | null /** Optional override; otherwise the logship_user_id cookie (none for public routes). */ salesRepId?: number | null /** Prefer the AD_User whose e-mail matches (partner source only). */ contactEmail?: string isComplete?: boolean } const trim = (s: any, max: number) => { const v = String(s ?? '').trim() return v.length > max ? v.slice(0, max - 1) + '…' : v } /** Public URL of a Strapi upload (`/uploads/x.pdf`) the way AttachmentModal links it. */ export const strapiPublicFileUrl = (fileUrl: string): string => { const u = String(fileUrl || '') if (!u) return '' if (/^https?:\/\//i.test(u)) return u const config: any = useRuntimeConfig() const base = String(config.public?.strapi || '').replace(/\/$/, '') return `${base}/files-api/${u.replace(/^\/?uploads\//, '').replace(/^\//, '')}` } /** Compose the Comments text: free text + the parseable document block. */ export const buildActivityComments = (text: string, documents: ActivityDocumentRef[] = []): string => { const docs = (documents || []).filter((d) => d && d.url) const head = String(text || '').trim() if (!docs.length) return trim(head, 1900) const block = `${ACTIVITY_DOCS_MARKER}\n${docs.map((d) => `${String(d.title || 'Dokument').replace(/\|/g, '/').trim()} | ${d.url}`).join('\n')}` // Comments is a 2000-char column — keep the document block intact, cut the prose. const room = 1990 - block.length - 2 return `${trim(head, Math.max(room, 200))}\n\n${block}` } /** Resolve the AD_User the activity hangs on (+ its org) for a lead or partner record. */ const resolveContact = async (event: any, token: string, input: ContactActivityInput): Promise<{ userId: number; orgId: number } | null> => { if (!(input.recordId > 0)) return null if (input.source === 'lead') { const user: any = await fetchHelper(event, `models/ad_user/${input.recordId}`, 'GET', token, null) if (!user?.id) return null return { userId: Number(user.id), orgId: Number(user?.AD_Org_ID?.id || 0) } } // Partner: the contact user — matching e-mail first, else the first active one. const res: any = await fetchHelper(event, `models/ad_user?$filter=${string.urlEncode(`C_BPartner_ID eq ${input.recordId} AND IsActive eq true`)}&$orderby=Created asc&$top=50`, 'GET', token, null) const users: any[] = Array.isArray(res?.records) ? res.records : [] if (!users.length) return null const mail = String(input.contactEmail || '').trim().toLowerCase() const pick = (mail && users.find((u) => String(u?.EMail || '').trim().toLowerCase() === mail)) || users[0] return { userId: Number(pick.id), orgId: Number(pick?.AD_Org_ID?.id || 0) } } /** * Write one activity. Returns the created record (or null on any failure). * `token` may be a session token (send routes) or the service token (public routes). */ export const logContactActivity = async (event: any, token: string, input: ContactActivityInput): Promise<any | null> => { try { if (!token) return null const contact = await resolveContact(event, token, input) if (!contact) { console.warn('[Activity] no contact user for', input.source, input.recordId, '— activity skipped') return null } let orgId = Number(input.organizationId || 0) if (!(orgId > 0)) orgId = Number(getCookie(event, 'logship_organization_id') || 0) if (!(orgId > 0)) orgId = contact.orgId let salesRepId = Number(input.salesRepId || 0) if (!(salesRepId > 0)) salesRepId = Number(getCookie(event, 'logship_user_id') || 0) const now = new Date().toJSON().replace(/\.\d{3}Z$/, 'Z') const body: any = { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, AD_User_ID: { id: contact.userId, tableName: 'AD_User' }, ContactActivityType: { id: input.type || 'EM' }, description: trim(input.description, 250), comments: buildActivityComments(input.comments || '', input.documents || []), StartDate: now, EndDate: now, isComplete: input.isComplete !== false, tableName: 'c_contactactivity' } if (salesRepId > 0) body.SalesRep_ID = { id: salesRepId, tableName: 'AD_User' } const res: any = await fetchHelper(event, 'models/c_contactactivity', 'POST', token, body) return res?.id ? res : null } catch (err: any) { console.error('[Activity] create failed:', err?.data?.detail || err?.message || err) return null } }