/** * Ticket auto-close: tickets that sit with the merchant (R_Request.IsMerchantAction) * and show no activity for N days (default 10) are closed, get a visible * "closed due to inactivity" entry in the chat history and the merchant is mailed. * * Runs with the iDempiere SERVICE token (no h3 event — timer plugin * server/plugins/ticket-auto-close.ts) and is also callable from the staff route * POST /api/requests/requests/auto-close ({ dryRun, days }). * * "No activity" = max(R_Request.Updated, newest r_requestupdate.Created) is older * than the cut-off. Both come back as Berlin wall-clock with a fake Z * (.claude/rules/idempiere/timestamps-timezone.md) → idempiereLocalToInstantMs. * * Order per ticket: close (PUT) → chat note (POST r_requestupdate, public 'C') * → merchant e-mail. A failed note or mail never undoes the close; both are * reported in the run summary. Once IsComplete is true the ticket never matches * again, so a re-run is idempotent. Reopened tickets (IsComplete false again) * only re-qualify when staff hand them back to the merchant. */ import nodemailer from 'nodemailer' import { string } from 'alga-js' import { idempiereLocalToInstantMs, berlinParts, pad2 } from './berlinTime' import { APP_BASE_URL } from './emailVerification' export const DEFAULT_INACTIVE_DAYS = 10 const MAX_CANDIDATES = 200 const MAIL_FROM = 'no-reply@logyou.de' /** Marker in the chat note — lets a reader (and a future check) recognise auto-closed tickets. */ export const AUTO_CLOSE_MARKER = '[Auto-Close]' export interface AutoCloseItem { id: number docNo: string orgId: number | null orgName: string lastActivity: string | null inactiveDays: number action: 'closed' | 'would-close' | 'skipped' | 'error' email: string emailSent: boolean noteAdded: boolean reason?: string } export interface AutoCloseSummary { runId: string trigger: string dryRun: boolean days: number cutoff: string scanned: number closed: number skipped: number errors: number durationMs: number items: AutoCloseItem[] error?: string } type Fetch = (path: string, method?: 'GET' | 'POST' | 'PUT', body?: any) => Promise const serviceFetch = (): Fetch => { const config: any = useRuntimeConfig() const base = String(config.api?.url || '').replace(/\/$/, '') const token = config.api?.idempieretoken if (!base || !token) throw new Error('iDempiere service token / url not configured (api.url, api.idempieretoken)') return (path, method = 'GET', body = null) => $fetch(`${base}/${path}`, { method, retry: 0, timeout: 90_000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + token }, ...(body ? { body } : {}) } as any) } const enc = (s: string) => string.urlEncode(s) /** Berlin wall-clock literal for a `$filter` on Created/Updated (compared against DB local time). */ const berlinFilterLiteral = (instantMs: number) => { const p = berlinParts(instantMs) return `${p.y}-${pad2(p.m)}-${pad2(p.d)} ${pad2(p.h)}:${pad2(p.mi)}:${pad2(p.s)}` } const escapeHtml = (s: string) => String(s || '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, ''') // Same subject derivation as action-notification.post.ts: R_Request.Name, else // the first two lines of the initial message (Summary). const deriveSubject = (name: string, summary: string) => { if (name) return name const lines = String(summary || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean) if (!lines.length) return '' const text = lines.slice(0, 2).join('\n') if (text.length > 160) return text.slice(0, 157).trimEnd() + '…' return lines.length > 2 ? text + ' …' : text } export const buildAutoCloseNote = (days: number) => `${AUTO_CLOSE_MARKER} Dieses Ticket wurde automatisch geschlossen, da seit ${days} Tagen keine Aktivität erfolgte ` + `(es wartete auf eine Rückmeldung des Händlers). Falls das Anliegen noch offen ist, kann das Ticket jederzeit ` + `wieder geöffnet oder einfach mit einer neuen Nachricht beantwortet werden.\n` + `This ticket was closed automatically after ${days} days without activity while waiting for the merchant's reply. ` + `If the matter is still open, simply reopen the ticket or reply with a new message.` const buildMerchantHtml = (o: { docNo: string, subject: string, days: number, link: string, isGerman: boolean }) => { const { docNo, subject, days, link, isGerman } = o const accent = '#d97706' const title = isGerman ? 'Ihr Ticket wurde wegen Inaktivität geschlossen' : 'Your ticket was closed due to inactivity' const intro = isGerman ? `Ihr Ticket wartete seit ${days} Tagen auf Ihre Rückmeldung und wurde daher automatisch geschlossen.` : `Your ticket had been waiting ${days} days for your reply and was therefore closed automatically.` const hint = isGerman ? 'Ist Ihr Anliegen noch offen? Dann öffnen Sie das Ticket einfach wieder oder antworten Sie mit einer neuen Nachricht – wir kümmern uns darum.' : 'Is your request still open? Simply reopen the ticket or reply with a new message and we will take care of it.' const cta = isGerman ? 'Ticket öffnen' : 'Open ticket' const subjectLabel = isGerman ? 'Thema' : 'Subject' const footer = isGerman ? 'Diese E-Mail wurde automatisch versendet. Bitte antworten Sie nicht direkt auf diese E-Mail.' : 'This email was sent automatically. Please do not reply directly to this email.' return `

${title}

${intro}

Ticket#${escapeHtml(docNo)}
${subjectLabel}${escapeHtml(subject).replace(/\n/g, '
') || '-'}
${hint}

${cta}

${footer}

` } /** Merchant contact of a ticket's org: ad_org.C_BPartner_ID → c_bpartner EMail / return_email / AD_Language. */ const loadMerchantContact = async (f: Fetch, orgId: number | null, cache: Map) => { if (!orgId) return { to: [] as string[], isGerman: true } const hit = cache.get(orgId) if (hit) return hit let contact = { to: [] as string[], isGerman: true } try { const org: any = await f(`models/ad_org/${orgId}?$select=C_BPartner_ID`) const bpId = org?.C_BPartner_ID?.id if (bpId) { const bp: any = await f(`models/c_bpartner/${bpId}?$select=EMail,AD_Language,return_email`) const to: string[] = [] const email = String(bp?.EMail || '').trim() const ret = String(bp?.return_email || '').trim() if (email) to.push(email) if (ret && ret !== email) to.push(ret) contact = { to, isGerman: String(bp?.AD_Language?.id || 'de_DE').startsWith('de') } } } catch (e: any) { console.warn(`[ticket-auto-close] merchant contact for org ${orgId} failed: ${e?.message || e}`) } cache.set(orgId, contact) return contact } let running = false export const runTicketAutoClose = async (opts: { dryRun?: boolean, days?: number, trigger?: string } = {}): Promise => { const started = Date.now() const runId = Math.random().toString(36).slice(2, 8) const days = Math.max(1, Math.floor(Number(opts.days) || DEFAULT_INACTIVE_DAYS)) const dryRun = opts.dryRun === true const cutoffMs = started - days * 86_400_000 const summary: AutoCloseSummary = { runId, trigger: opts.trigger || 'manual', dryRun, days, cutoff: new Date(cutoffMs).toISOString(), scanned: 0, closed: 0, skipped: 0, errors: 0, durationMs: 0, items: [] } if (running) { summary.error = 'A run is already in progress' summary.durationMs = Date.now() - started return summary } running = true try { const f = serviceFetch() const filter = `IsActive eq true and IsComplete eq false and IsMerchantAction eq true and Updated le '${berlinFilterLiteral(cutoffMs)}'` const res: any = await f(`models/r_request?$filter=${enc(filter)}&$select=DocumentNo,Name,Summary,Updated,AD_Org_ID&$orderby=${enc('Updated asc')}&$top=${MAX_CANDIDATES}`) const candidates: any[] = res?.records || [] summary.scanned = candidates.length const contactCache = new Map() const note = buildAutoCloseNote(days) const transporter = dryRun ? null : nodemailer.createTransport({ host: 'localhost', port: 25, secure: false, tls: { rejectUnauthorized: false } }) for (const t of candidates) { const id = Number(t.id) const item: AutoCloseItem = { id, docNo: String(t.DocumentNo || id), orgId: t.AD_Org_ID?.id ?? null, orgName: t.AD_Org_ID?.identifier || '', lastActivity: null, inactiveDays: 0, action: 'skipped', email: '', emailSent: false, noteAdded: false } summary.items.push(item) try { // Newest chat entry — a merchant reply does not necessarily bump R_Request.Updated. let lastMs = idempiereLocalToInstantMs(t.Updated) ?? 0 const upd: any = await f(`models/r_requestupdate?$filter=${enc('R_Request_ID eq ' + id)}&$select=Created&$orderby=${enc('Created desc')}&$top=1`) const newest = upd?.records?.[0]?.Created const newestMs = idempiereLocalToInstantMs(newest) if (newestMs && newestMs > lastMs) lastMs = newestMs item.lastActivity = lastMs ? new Date(lastMs).toISOString() : null item.inactiveDays = lastMs ? Math.floor((started - lastMs) / 86_400_000) : 0 if (lastMs > cutoffMs) { item.reason = 'recent chat activity' summary.skipped++ continue } const contact = await loadMerchantContact(f, item.orgId, contactCache) item.email = contact.to.join(', ') if (dryRun) { item.action = 'would-close' summary.closed++ continue } // 1. close + hand back (same flags as the merchant's own "Ticket gelöst") await f(`models/r_request/${id}`, 'PUT', { isComplete: true, isMerchantAction: false, tableName: 'R_Request' }) item.action = 'closed' summary.closed++ // 2. visible chat-history entry (public, the ticket's own org — see request-update/index.post.ts) try { await f('models/r_requestupdate', 'POST', { ...(item.orgId ? { AD_Org_ID: { id: item.orgId, tableName: 'AD_Org' } } : {}), R_Request_ID: { id, tableName: 'R_Request' }, ConfidentialTypeEntry: { id: 'C' }, result: note, tableName: 'R_RequestUpdate' }) item.noteAdded = true } catch (e: any) { item.reason = 'note failed: ' + (e?.data?.detail || e?.message || e) console.error(`[ticket-auto-close] note for ticket ${id} failed:`, item.reason) } // 3. merchant e-mail if (!contact.to.length) { item.reason = (item.reason ? item.reason + '; ' : '') + 'no merchant e-mail' continue } try { const subject = deriveSubject(String(t.Name || '').trim(), String(t.Summary || '')) await transporter!.sendMail({ from: MAIL_FROM, to: contact.to.join(', '), subject: contact.isGerman ? `[Ticket #${item.docNo}] Ihr Ticket wurde wegen Inaktivität geschlossen` : `[Ticket #${item.docNo}] Your ticket was closed due to inactivity`, html: buildMerchantHtml({ docNo: item.docNo, subject, days, link: `${APP_BASE_URL}/requests/my-tickets/${id}`, isGerman: contact.isGerman }) }) item.emailSent = true } catch (e: any) { item.reason = (item.reason ? item.reason + '; ' : '') + 'mail failed: ' + (e?.message || e) console.error(`[ticket-auto-close] mail for ticket ${id} failed:`, e?.message || e) } } catch (e: any) { item.action = 'error' item.reason = e?.data?.detail || e?.data?.message || e?.message || String(e) summary.errors++ console.error(`[ticket-auto-close] ticket ${id} failed:`, item.reason) } } } catch (e: any) { summary.error = e?.data?.detail || e?.message || String(e) console.error('[ticket-auto-close] run failed:', summary.error) } finally { running = false summary.durationMs = Date.now() - started } return summary }