/**
* 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]
*
|
* |
*
* `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 => {
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
}
}