/** * POST /api/offers/contract/send * Body: contract payload incl. email { to, cc?, subject, message }, * attachments { offer, agb, adsp, logistikagb, avv } (false = leave out), * signing { enabled, expiryDays, expiresAt }. * * Builds the branded Fulfillment-Rahmenvertrag PDF ONCE, assembles the annex * package (contractPackage.ts), e-mails everything to the recipient (from * info@logyou.de) and attaches the contract PDF to the triggering record * (partner → C_BPartner, lead → Lead_User). With digital signing enabled a * signing request is created (contractSigningDb.ts), the exact PDFs are stored * for the portal and the mail carries the /sign/ CTA (valid N days, * default 7; every new send supersedes the record's older pending links). */ import refreshTokenHelper from '../../../utils/refreshTokenHelper' import errorHandlingHelper from '../../../utils/errorHandlingHelper' import forceLogoutHelper from '../../../utils/forceLogoutHelper' import { BRAND, LEGAL_FOOTER_LINES } from '../../../utils/offers/quotePdf' import { generateContractPdf } from '../../../utils/offers/contractPdf' import { attachToStrapi } from '../../../utils/inbox/strapiAttach' import { saveOfferConditions, conditionsFromContractPayload } from '../../../utils/offers/offerConditions' import { assembleContractPackage } from '../../../utils/offers/contractPackage' import { createSigningRequest, isSigningAvailable, writeSigningFile } from '../../../utils/offers/contractSigningDb' import { buildDocumentMeta, mailTransport, resolveSigningExpiry, signingCtaHtml, signingCtaText, signingLink, escapeHtml, formatDateHuman } from '../../../utils/offers/contractSigning' import { logContactActivity, strapiPublicFileUrl } from '../../../utils/offers/contactActivity' import { APP_BASE_URL } from '../../../utils/emailVerification' const EMAIL_TEXTS: any = { de: { headline: 'Ihr Fulfillment-Vertrag', attachmentBox: 'Der Vertrag liegt dieser E-Mail als PDF-Anhang bei. Bitte senden Sie uns ein unterschriebenes Exemplar zurück.', attachmentBoxSigning: 'Der Vertrag und alle Anlagen liegen dieser E-Mail als PDF bei. Über den Button oben können Sie die Dokumente auch online einsehen und den Vertrag dort — wenn Sie möchten — in einem separaten Schritt digital unterschreiben, oder uns alternativ ein unterschriebenes Exemplar zurücksenden.', linkOnlyBox: 'Alle Dokumente stehen über den Button oben zum Lesen und Herunterladen bereit — der Klick ist unverbindlich. Falls Sie den Vertrag dort unterschreiben, erhalten Sie automatisch eine Kopie aller Dokumente per E-Mail.', documents: 'Enthaltene Dokumente', auto: 'Diese E-Mail wurde automatisch versendet.' }, en: { headline: 'Your fulfillment agreement', attachmentBox: 'The agreement is attached to this email as a PDF. Please return a signed copy to us.', attachmentBoxSigning: 'The agreement and all annexes are attached to this email as PDFs. Via the button above you can also review the documents online and — if you wish — sign the agreement there in a separate step, or alternatively return a signed copy to us.', linkOnlyBox: 'All documents are available for reading and download via the button above — clicking it is non-binding. If you sign the agreement there, you will automatically receive a copy of every document by e-mail.', documents: 'Included documents', auto: 'This email was sent automatically.' } } const generateContractEmailHtml = (message: string, language: string, docs: Array<{ title: string; filename: string }>, signing: { link: string; expiresAt: string } | null, attachDocs: boolean) => { const T = EMAIL_TEXTS[language === 'en' ? 'en' : 'de'] const messageHtml = escapeHtml(message).replace(/\n/g, '
') const docList = docs.length > 1 ? `
${T.documents}:
` : '' return `
${signing ? `` : ''}
${T.headline}
LogYou GmbH · logyou.de
${messageHtml}
${signingCtaHtml(language, signing.link, signing.expiresAt)}
📄  ${signing ? (attachDocs ? T.attachmentBoxSigning : T.linkOnlyBox) : T.attachmentBox} ${docList}
${LEGAL_FOOTER_LINES.join('
')}

${T.auto}
` } const handleFunc = async (event: any, authToken: string | null = null) => { const token = authToken ?? await getTokenHelper(event) if (!token) return { status: 401, message: 'Not authenticated' } const body = await readBody(event) if (!body?.customer?.company || !body?.customer?.contactName) { return { status: 400, message: 'Company and contact name are required' } } const toEmail = String(body?.email?.to || '').trim() if (!/.+@.+\..+/.test(toEmail)) { return { status: 400, message: 'A valid recipient email is required' } } const language = body?.meta?.language === 'en' ? 'en' : 'de' const source = body.source === 'lead' ? 'lead' : 'partner' const recordId = Number(body.recordId) const warnings: string[] = [] // 1) Contract PDF once — e-mail attachment, record attachment, portal copy. const contract = await generateContractPdf(body) // 2) Annex package (last offer, AGB, ADSp, Logistik-AGB, AVV) per the flags. const pkg = await assembleContractPackage(event, token, { source, recordId, language, attachments: body.attachments || {}, contract, sepa: body.sepa || {}, customer: body.customer || {} }) warnings.push(...pkg.warnings) // 3) Digital signing request (fail-soft: without the store we still send the mail). let signing: { token: string; link: string; expiresAt: string } | null = null const signingWanted = body?.signing?.enabled !== false if (signingWanted) { if (!isSigningAvailable()) { warnings.push(language === 'en' ? 'Digital signing unavailable (store) — sent without signing link' : 'Digitale Unterschrift nicht verfügbar (Speicher) — ohne Signatur-Link versendet') } else { try { const expiresAt = resolveSigningExpiry(body.signing) const docsMeta = pkg.documents.map((d) => buildDocumentMeta(d.key, d.filename, d.buffer, language, d.version)) const { email: _e, ...payloadWithoutEmail } = body if (pkg.mandate) (payloadWithoutEmail as any).mandate = pkg.mandate // SEPA annex data for the portal + signed render const req = createSigningRequest({ source, recordId: recordId > 0 ? recordId : 0, recordUu: body.recordUu || undefined, company: String(body.customer.company), contactName: String(body.customer.contactName), email: toEmail, language, payload: payloadWithoutEmail, documents: docsMeta, createdBy: Number(getCookie(event, 'logship_user_id')) || null, expiresAt }) for (const d of pkg.documents) writeSigningFile(req.token, d.filename, d.buffer) signing = { token: req.token, link: signingLink(req.token), expiresAt } } catch (err: any) { console.error('Signing request creation failed:', err) warnings.push(language === 'en' ? 'Signing link could not be created — sent without it' : 'Signatur-Link konnte nicht erstellt werden — ohne Link versendet') } } } // 4) Mail. const ccList = String(body?.email?.cc || '') .split(/[,;]/) .map((s: string) => s.trim()) .filter((s: string) => s.length > 0) const message = String(body?.email?.message || '') // With a signing link the PDFs are opt-in (body.signing.attachDocuments); without a // link they are always attached — the mail is the only way to get them. const attachDocs = !signing || body?.signing?.attachDocuments === true try { await mailTransport().sendMail({ from: 'info@logyou.de', to: toEmail, cc: ccList.length > 0 ? ccList : undefined, subject: body?.email?.subject || contract.filename.replace(/\.pdf$/, ''), text: message + (signing ? signingCtaText(language, signing.link, signing.expiresAt) : ''), html: generateContractEmailHtml(message, language, pkg.documents, signing, attachDocs), attachments: attachDocs ? pkg.documents.map((d) => ({ filename: d.filename, content: d.buffer, contentType: 'application/pdf' })) : [] }) } catch (err: any) { console.error('Contract email send error:', err) return { status: 500, message: `Failed to send email: ${err.message}` } } // 5) Attach the contract PDF to the triggering record — own try/catch so an // attachment failure never reverts the successful send. let attached = false let attachedUrl = '' try { if (recordId > 0) { const up = await attachToStrapi(event, token, { tableName: source === 'lead' ? 'Lead_User' : 'C_BPartner', recordId, recordUu: body.recordUu || undefined, buffer: contract.buffer, filename: contract.filename, mimeType: 'application/pdf' }) attached = true attachedUrl = strapiPublicFileUrl(up.fileUrl) } else { warnings.push('No record id — attachment skipped') } } catch (err: any) { console.error('Contract attachment error:', err) warnings.push('Email sent, but attaching the PDF to the record failed') } // 6) Persist the just-sent conditions (sections, attachments, signing prefs) // on the record — fail-soft, the offer_conditions column may not exist yet. let conditionsSaved = false try { if (recordId > 0) { await saveOfferConditions(event, token, source, recordId, 'contract', { ...conditionsFromContractPayload(body), lastSend: { at: new Date().toISOString(), to: toEmail, documents: pkg.documents.map((d) => ({ key: d.key, filename: d.filename, version: d.version })), signingToken: signing?.token || null, signingExpiresAt: signing?.expiresAt || null } }) conditionsSaved = true } } catch (err: any) { console.error('Contract conditions save skipped:', err?.message || err) } // 7) Activity on the record: "Vertrag gesendet" with every document as a button // (contract → Strapi attachment; annexes → portal copies while a signing link // exists). Fail-soft. let activityLogged = false try { if (recordId > 0) { const en = language === 'en' const docTitles = pkg.documents.map((d) => d.title).join(', ') const lines = [ en ? `Contract ${contract.filename} e-mailed to ${toEmail}${ccList.length ? ` (CC: ${ccList.join(', ')})` : ''}.` : `Vertrag ${contract.filename} per E-Mail an ${toEmail}${ccList.length ? ` (CC: ${ccList.join(', ')})` : ''} gesendet.`, en ? `Package: ${docTitles}.` : `Paket: ${docTitles}.`, signing ? (en ? `Digital signing link (valid until ${formatDateHuman(signing.expiresAt, 'en')}${attachDocs ? ', PDFs also attached' : ', PDFs in portal only'}): ${signing.link}` : `Signatur-Link (gültig bis ${formatDateHuman(signing.expiresAt, 'de')}${attachDocs ? ', PDFs zusätzlich angehängt' : ', PDFs nur im Portal'}): ${signing.link}`) : (en ? 'No signing link — customer asked to return a signed copy.' : 'Ohne Signatur-Link — Kunde soll unterschriebenes Exemplar zurücksenden.') ] const docs: Array<{ title: string; url: string }> = [] if (attachedUrl) docs.push({ title: en ? 'Contract (PDF)' : 'Vertrag (PDF)', url: attachedUrl }) if (signing) { for (const d of pkg.documents) { if (d.key === 'contract' && attachedUrl) continue docs.push({ title: d.title, url: `${APP_BASE_URL}/api/public/sign/${signing.token}/document/${d.key}` }) } } const created = await logContactActivity(event, token, { source, recordId, type: 'EM', contactEmail: toEmail, description: en ? `Contract sent: ${contract.filename}` : `Vertrag gesendet: ${contract.filename}`, comments: lines.join('\n'), documents: docs }) activityLogged = !!created } } catch (err: any) { console.error('Contract activity skipped:', err?.message || err) } return { status: 200, message: 'Contract sent successfully', filename: contract.filename, attached, conditionsSaved, activityLogged, documents: pkg.documents.map((d) => ({ key: d.key, title: d.title, filename: d.filename, version: d.version })), signing, attachedToMail: attachDocs, warning: warnings.length ? warnings.join(' · ') : undefined } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })