/** * Digital contract signing — shared logic for the send route (link creation) * and the PUBLIC portal routes (/api/public/sign//*). * * Lifecycle: contract send with `signing.enabled` → createSigningRequest() * (contractSigningDb.ts) + the exact PDFs stored on disk + a CTA link in the * contract e-mail → the lead opens /sign/, reads the documents and * signs → finalizeSignature(): the contract is RE-RENDERED with the signature * block filled + a signature-certificate page, stored, attached to the * lead/partner record (Strapi), written into offer_conditions.contract.signature, * mailed to the signer (all documents) and to info@logyou.de (all documents + * record link). Expired links offer a "request new contract" button that * e-mails info@logyou.de with the record link. * * Public-route writes use the service token (config.api.idempieretoken) — * the token in the link is the only authorization, exactly like * email-verification/confirm. */ import nodemailer from 'nodemailer' import { BRAND, LEGAL_FOOTER_LINES } from './quotePdf' import { generateContractPdf, type ContractSignature } from './contractPdf' import { packageTitle } from './contractPackage' import { attachToStrapi } from '../inbox/strapiAttach' import { patchOfferConditionsSection } from './offerConditions' import { APP_BASE_URL } from '../emailVerification' import { logContactActivity, strapiPublicFileUrl } from './contactActivity' import { type SigningRequest, type SigningDocumentMeta, readSigningFile, writeSigningFile, sha256Hex, markSigningRequestSigned, markSigningRequestRenewalRequested } from './contractSigningDb' export const NOTIFY_EMAIL = 'info@logyou.de' export const DEFAULT_SIGNING_DAYS = 7 export const signingLink = (token: string) => `${APP_BASE_URL}/sign/${token}` export const recordLink = (source: string, recordId: number) => source === 'lead' ? `${APP_BASE_URL}/edit/lead-${recordId}` : `${APP_BASE_URL}/partners/partners/${recordId}/edit` export const escapeHtml = (s: any) => String(s ?? '') .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') export const formatDateHuman = (iso: string, language: string) => { const d = new Date(iso) if (isNaN(d.getTime())) return iso const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') return language === 'en' ? `${d.getFullYear()}-${mm}-${dd}` : `${dd}.${mm}.${d.getFullYear()}` } export const formatDateTimeHuman = (iso: string, language: string) => { const d = new Date(iso) if (isNaN(d.getTime())) return iso const hh = String(d.getHours()).padStart(2, '0') const mi = String(d.getMinutes()).padStart(2, '0') return `${formatDateHuman(iso, language)} ${hh}:${mi}` } /** Resolve the expiry the modal asked for: explicit date (end of day) or N days. */ export const resolveSigningExpiry = (signing: any): string => { const days = Number(signing?.expiryDays) const explicit = String(signing?.expiresAt || '').trim() if (/^\d{4}-\d{2}-\d{2}$/.test(explicit)) { const d = new Date(`${explicit}T23:59:59`) if (!isNaN(d.getTime()) && d.getTime() > Date.now()) return d.toISOString() } const n = Number.isFinite(days) && days > 0 ? Math.min(days, 365) : DEFAULT_SIGNING_DAYS const d = new Date() d.setDate(d.getDate() + n) d.setHours(23, 59, 59, 0) return d.toISOString() } export const mailTransport = () => nodemailer.createTransport({ host: 'localhost', port: 25, secure: false, tls: { rejectUnauthorized: false } }) /** Branded e-mail shell shared by all signing mails (same look as the contract mail). */ export const brandedEmail = (headline: string, bodyHtml: string, footNote: string) => `
${headline}
LogYou GmbH · logyou.de
${bodyHtml}
${LEGAL_FOOTER_LINES.join('
')}

${footNote}
` /** CTA block inserted into the contract e-mail when digital signing is on. */ export const signingCtaHtml = (language: string, link: string, expiresAt: string) => { const en = language === 'en' const title = en ? 'Review the documents online' : 'Dokumente online einsehen' const text = en ? 'The button opens all documents for reading and download. Clicking it does not sign anything yet — the digital signature is a separate, optional step on that page, which you only complete if and when you decide to (no printing or scanning required).' : 'Über den Button öffnen Sie alle Dokumente zum Lesen und Herunterladen. Mit dem Klick unterschreiben Sie noch nichts — die digitale Unterschrift ist ein separater, optionaler Schritt auf der Seite, den Sie erst dann ausführen, wenn Sie sich dafür entscheiden (ohne Ausdrucken und Scannen).' const btn = en ? 'Open documents' : 'Dokumente öffnen' const note = en ? 'Viewing only — clicking does not sign anything.' : 'Nur ansehen — der Klick ist unverbindlich.' const valid = en ? `This link is valid until ${formatDateHuman(expiresAt, 'en')}.` : `Der Link ist gültig bis ${formatDateHuman(expiresAt, 'de')}.` return `
📄  ${title}
${text}

${btn}

${note}
${valid}
` } export const signingCtaText = (language: string, link: string, expiresAt: string) => language === 'en' ? `\n\nReview the documents online (signing there is optional and a separate step): ${link}\n(valid until ${formatDateHuman(expiresAt, 'en')})` : `\n\nDokumente online einsehen (die Unterschrift ist dort optional und ein separater Schritt): ${link}\n(gültig bis ${formatDateHuman(expiresAt, 'de')})` export const buildDocumentMeta = (key: string, filename: string, buffer: Buffer, language: string, version?: number): SigningDocumentMeta => ({ key, title: packageTitle(key, language), filename, sha256: sha256Hex(buffer), size: buffer.length, ...(version ? { version } : {}) }) /** * Exact wording of the declarations the signer must tick. Kept SERVER-SIDE so * the certificate records the canonical text (Art. 28 (9) DSGVO evidence), * and served to the portal so both always match. Never pre-selected. */ export const acceptanceTexts = (language: string, documents: SigningDocumentMeta[]) => { const en = language === 'en' const annexes = documents.filter((d) => d.key !== 'contract' && d.key !== 'avv' && d.key !== 'flyer').map((d) => d.title.replace(/^(Anlage|Annex) \S+ – /, '')).join(', ') const hasAvv = documents.some((d) => d.key === 'avv') const avv = documents.find((d) => d.key === 'avv') const list: Array<{ key: string; text: string; required: boolean }> = [ { key: 'documents', required: true, text: en ? `I have read the fulfillment agreement${annexes ? ` and its annexes (${annexes})` : ''} and accept them.` : `Ich habe den Fulfillment-Rahmenvertrag${annexes ? ` sowie die Anlagen (${annexes})` : ''} gelesen und akzeptiere diese.` } ] if (hasAvv) { list.push({ key: 'avv', required: true, text: en ? `I have read the data processing agreement pursuant to Art. 28 GDPR${avv?.version ? ` (version ${avv.version})` : ''} and conclude it on behalf of my company.` : `Ich habe den Auftragsverarbeitungsvertrag gemäß Art. 28 DSGVO${avv?.version ? ` (Version ${avv.version})` : ''} gelesen und schließe ihn im Namen meines Unternehmens ab.` }) } list.push({ key: 'authorized', required: true, text: en ? 'I am authorised to represent the named company and to conclude this agreement and the data processing agreement on its behalf.' : 'Ich bin vertretungsberechtigt bzw. bevollmächtigt, diesen Vertrag und den Auftragsverarbeitungsvertrag im Namen des genannten Unternehmens abzuschließen.' }) list.push({ key: 'esign', required: true, text: en ? 'I agree to sign this agreement electronically; my electronic signature is binding.' : 'Ich stimme zu, diesen Vertrag elektronisch zu unterzeichnen; meine elektronische Unterschrift ist verbindlich.' }) return list } export interface SignerInput { company: string contactName: string position?: string street: string zip: string city: string country: string ustId?: string email: string phone?: string registerNo?: string plannedStart?: string // YYYY-MM-DD, chosen by the signer (≤ 8 weeks out) } const isGermany = (country: string) => /^(de|deutschland|germany)$/i.test(String(country || '').trim()) /** Server-side validation mirroring the portal form. Returns a message or ''. */ export const validateSigner = (s: SignerInput, language: string): string => { const en = language === 'en' const req = (v: any) => String(v || '').trim() !== '' if (!req(s.company)) return en ? 'Company is required' : 'Firma ist ein Pflichtfeld' if (!req(s.contactName)) return en ? 'Name is required' : 'Name ist ein Pflichtfeld' if (!req(s.street)) return en ? 'Street is required' : 'Straße ist ein Pflichtfeld' if (!req(s.zip)) return en ? 'ZIP code is required' : 'PLZ ist ein Pflichtfeld' if (!req(s.city)) return en ? 'City is required' : 'Ort ist ein Pflichtfeld' if (!req(s.country)) return en ? 'Country is required' : 'Land ist ein Pflichtfeld' if (!/.+@.+\..+/.test(String(s.email || '').trim())) return en ? 'A valid e-mail is required' : 'Eine gültige E-Mail-Adresse ist erforderlich' if (!isGermany(s.country) && !req(s.ustId)) return en ? 'VAT ID is required outside Germany' : 'Außerhalb Deutschlands ist die USt-IdNr. ein Pflichtfeld' return '' } const attachmentsFor = (req: SigningRequest, docs: SigningDocumentMeta[]) => docs .map((d) => { const content = readSigningFile(req.token, d.filename) return content ? { filename: d.filename, content, contentType: 'application/pdf' } : null }) .filter(Boolean) as any[] /** * Sign: re-render the contract with the signature, store + attach + notify. * Returns the signed document list. Throws only when the signed PDF cannot be * produced/stored — every downstream step is fail-soft and reported in `warnings`. */ export const finalizeSignature = async (event: any, req: SigningRequest, signer: SignerInput, signatureDataUrl: string, meta: { ip: string; userAgent: string }, acceptances: Array<{ key: string; text: string; acceptedAt: string }> = []) => { const warnings: string[] = [] const language = req.language const signedAt = new Date().toISOString() const signature: ContractSignature = { imageDataUrl: signatureDataUrl, name: signer.contactName, position: signer.position, company: signer.company, street: signer.street, zip: signer.zip, city: signer.city, country: signer.country, ustId: signer.ustId, email: signer.email, phone: signer.phone, signedAt, ip: meta.ip, userAgent: meta.userAgent, token: req.token, documents: req.documents.map((d) => ({ title: d.title, filename: d.filename, sha256: d.sha256, version: d.version })), acceptances } // The signed contract carries the signer's confirmed data in its signature // block (the customer may have corrected address/VAT while signing). const payload = JSON.parse(JSON.stringify(req.payload || {})) payload.customer = { ...(payload.customer || {}), company: signer.company, contactName: signer.contactName, signerPosition: signer.position || payload.customer?.signerPosition || '', street: signer.street, zip: signer.zip, city: signer.city, country: signer.country, ustId: signer.ustId || '', email: signer.email, phone: signer.phone || payload.customer?.phone || '' } if (signer.plannedStart) { payload.assumptions = { ...(payload.assumptions || {}), plannedStart: signer.plannedStart } } const { buffer: signedBuffer, filename: signedFilename } = await generateContractPdf(payload, { signature, filenameSuffix: language === 'en' ? '_signed' : '_unterschrieben' }) writeSigningFile(req.token, signedFilename, signedBuffer) const signedContractMeta: SigningDocumentMeta = { ...buildDocumentMeta('contract', signedFilename, signedBuffer, language), signed: true } const signedDocuments: SigningDocumentMeta[] = [signedContractMeta, ...req.documents.filter((d) => d.key !== 'contract')] const signerRecord = { ...signer, signedAt, ip: meta.ip, userAgent: meta.userAgent, acceptances } markSigningRequestSigned(req.token, signerRecord, signedDocuments, signedAt) const config: any = useRuntimeConfig() const serviceToken: string = config.api?.idempieretoken || '' // 1) Attach the signed contract to the record (Strapi, same flow as the send). let attachedUrl = '' try { if (!(req.recordId > 0)) throw new Error('no record id') const up = await attachToStrapi(event, serviceToken, { tableName: req.source === 'lead' ? 'Lead_User' : 'C_BPartner', recordId: req.recordId, recordUu: req.recordUu || undefined, buffer: signedBuffer, filename: signedFilename, mimeType: 'application/pdf' }) attachedUrl = strapiPublicFileUrl(up.fileUrl) } catch (err: any) { console.error('[Signing] attach signed contract failed:', err?.message || err) warnings.push('attach') } // 1b) Activity on the record: "Vertrag digital unterschrieben" + document buttons. try { const en = language === 'en' const docs: Array<{ title: string; url: string }> = [ { title: en ? 'Signed contract (PDF)' : 'Unterschriebener Vertrag (PDF)', url: attachedUrl || `${APP_BASE_URL}/api/public/sign/${req.token}/document/contract` }, ...signedDocuments.filter((d) => d.key !== 'contract').map((d) => ({ title: d.title, url: `${APP_BASE_URL}/api/public/sign/${req.token}/document/${d.key}` })) ] const created = await logContactActivity(event, serviceToken, { source: req.source, recordId: req.recordId, type: 'EM', contactEmail: req.email, description: en ? `Contract signed digitally: ${signer.company}` : `Vertrag digital unterschrieben: ${signer.company}`, comments: en ? `${signer.contactName}${signer.position ? ` (${signer.position})` : ''}, ${signer.company}, signed the fulfillment agreement on ${formatDateTimeHuman(signedAt, 'en')} via the online portal.\nAddress: ${signer.street}, ${signer.zip} ${signer.city}, ${signer.country} · VAT ID: ${signer.ustId || '—'}\nE-mail: ${signer.email}${signer.phone ? ` · Phone: ${signer.phone}` : ''}${signer.plannedStart ? `\nDesired start: ${formatDateHuman(signer.plannedStart, 'en')}` : ''}` : `${signer.contactName}${signer.position ? ` (${signer.position})` : ''}, ${signer.company}, hat den Fulfillment-Rahmenvertrag am ${formatDateTimeHuman(signedAt, 'de')} über das Online-Portal digital unterschrieben.\nAnschrift: ${signer.street}, ${signer.zip} ${signer.city}, ${signer.country} · USt-IdNr.: ${signer.ustId || '—'}\nE-Mail: ${signer.email}${signer.phone ? ` · Telefon: ${signer.phone}` : ''}${signer.plannedStart ? `\nGewünschter Start: ${formatDateHuman(signer.plannedStart, 'de')}` : ''}`, documents: docs }) if (!created) warnings.push('activity') } catch (err: any) { console.error('[Signing] activity failed:', err?.message || err) warnings.push('activity') } // 2) Persist the signature on the record's offer conditions (fail-soft). try { if (!(req.recordId > 0)) throw new Error('no record id') await patchOfferConditionsSection(event, serviceToken, req.source, req.recordId, 'contract', { customer: payload.customer, ...(signer.plannedStart ? { assumptions: { ...(req.payload?.assumptions || {}), plannedStart: signer.plannedStart } } : {}), signature: { signedAt, name: signer.contactName, company: signer.company, position: signer.position || '', email: signer.email, ustId: signer.ustId || '', filename: signedFilename, token: req.token, ip: meta.ip, documents: signedDocuments.map((d) => ({ key: d.key, filename: d.filename, sha256: d.sha256, version: d.version })), // AVV conclusion record (Art. 28 (9) DSGVO evidence) — version + hash + exact wording avv: (() => { const d = req.documents.find((x) => x.key === 'avv') const a = acceptances.find((x) => x.key === 'avv') return d ? { version: d.version || null, sha256: d.sha256, filename: d.filename, acceptedAt: a?.acceptedAt || signedAt, text: a?.text || '' } : null })(), acceptances } }) } catch (err: any) { console.error('[Signing] offer_conditions signature save skipped:', err?.message || err) warnings.push('conditions') } // 3) Mail the signer a copy of everything. const attachments = attachmentsFor(req, signedDocuments) const en = language === 'en' const docList = `` try { await mailTransport().sendMail({ from: NOTIFY_EMAIL, to: signer.email, subject: en ? `Your signed fulfillment agreement – ${signer.company}` : `Ihr unterschriebener Fulfillment-Vertrag – ${signer.company}`, text: en ? `Dear ${signer.contactName},\n\nthank you — your agreement was signed digitally on ${formatDateTimeHuman(signedAt, 'en')}. All documents are attached to this e-mail.\n\nBest regards\nYour LogYou team` : `Guten Tag ${signer.contactName},\n\nvielen Dank — Ihr Vertrag wurde am ${formatDateTimeHuman(signedAt, 'de')} digital unterschrieben. Alle Dokumente finden Sie im Anhang dieser E-Mail.\n\nMit freundlichen Grüßen\nIhr LogYou Team`, html: brandedEmail( en ? 'Your signed agreement' : 'Ihr unterschriebener Vertrag', en ? `Dear ${escapeHtml(signer.contactName)},

thank you — your fulfillment agreement was signed digitally on ${formatDateTimeHuman(signedAt, 'en')}. A copy of every document is attached to this e-mail:${docList}
We look forward to working with you. Our onboarding team will get in touch shortly.

Best regards
Your LogYou team` : `Guten Tag ${escapeHtml(signer.contactName)},

vielen Dank — Ihr Fulfillment-Rahmenvertrag wurde am ${formatDateTimeHuman(signedAt, 'de')} digital unterschrieben. Eine Kopie aller Dokumente finden Sie im Anhang dieser E-Mail:${docList}
Wir freuen uns auf die Zusammenarbeit. Unser Onboarding-Team meldet sich in Kürze bei Ihnen.

Mit freundlichen Grüßen
Ihr LogYou Team`, en ? 'This email was sent automatically.' : 'Diese E-Mail wurde automatisch versendet.' ), attachments }) } catch (err: any) { console.error('[Signing] signer copy mail failed:', err?.message || err) warnings.push('mail-signer') } // 4) Notify LogYou with all documents + record link. try { const link = recordLink(req.source, req.recordId) const rows = [ ['Firma', signer.company], ['Name', signer.contactName], ['Position', signer.position || '—'], ['Anschrift', `${signer.street}, ${signer.zip} ${signer.city}, ${signer.country}`], ['USt-IdNr.', signer.ustId || '—'], ['E-Mail', signer.email], ['Telefon', signer.phone || '—'], ['Handelsregister', signer.registerNo || '—'], ['Gewünschter Start', signer.plannedStart ? formatDateHuman(signer.plannedStart, 'de') : '—'], ['Unterschrieben am', formatDateTimeHuman(signedAt, 'de')], ['IP', meta.ip], ['Browser', meta.userAgent] ] const table = `${rows.map(([k, v]) => ``).join('')}
${escapeHtml(k)}${escapeHtml(v)}
` await mailTransport().sendMail({ from: NOTIFY_EMAIL, to: NOTIFY_EMAIL, subject: `✅ Vertrag digital unterschrieben – ${signer.company}`, text: `${signer.company} (${signer.contactName}) hat den Fulfillment-Rahmenvertrag am ${formatDateTimeHuman(signedAt, 'de')} digital unterschrieben.\n\nDatensatz: ${link}`, html: brandedEmail( 'Vertrag digital unterschrieben', `${escapeHtml(signer.company)} (${escapeHtml(signer.contactName)}) hat den Fulfillment-Rahmenvertrag digital unterschrieben.${table}Alle Dokumente sind angehängt; der unterschriebene Vertrag wurde zusätzlich am ${req.source === 'lead' ? 'Lead' : 'Geschäftspartner'} als Anhang gespeichert.

${req.source === 'lead' ? 'Lead öffnen' : 'Geschäftspartner öffnen'}${warnings.length ? `

Hinweis: folgende Schritte sind fehlgeschlagen und müssen manuell geprüft werden: ${warnings.join(', ')}

` : ''}`, 'Automatische Benachrichtigung des LogShip-Signaturportals.' ), attachments }) } catch (err: any) { console.error('[Signing] internal notification mail failed:', err?.message || err) warnings.push('mail-internal') } return { signedAt, signedDocuments, signedFilename, warnings } } /** "The link expired — please send me a new contract/offer" → info@logyou.de. */ export const sendRenewalRequest = async (req: SigningRequest, kind: 'contract' | 'offer' | 'both', message: string, meta: { ip: string }) => { const link = recordLink(req.source, req.recordId) const what = kind === 'offer' ? 'ein neues Angebot' : kind === 'both' ? 'ein neues Angebot und einen neuen Vertrag' : 'einen neuen Vertrag' const msg = String(message || '').trim().slice(0, 2000) await mailTransport().sendMail({ from: NOTIFY_EMAIL, to: NOTIFY_EMAIL, subject: `🔁 Anfrage: ${what} – ${req.company || req.contactName}`, text: `${req.company} (${req.contactName}, ${req.email}) bittet um ${what}. Der Signatur-Link vom ${formatDateHuman(req.createdAt, 'de')} ist am ${formatDateHuman(req.expiresAt, 'de')} abgelaufen.\n\n${msg ? `Nachricht: ${msg}\n\n` : ''}Datensatz: ${link}`, html: brandedEmail( 'Neues Angebot / Vertrag angefordert', `${escapeHtml(req.company)} (${escapeHtml(req.contactName)}, ${escapeHtml(req.email)}) bittet um ${what}.

Der Signatur-Link vom ${formatDateHuman(req.createdAt, 'de')} ist am ${formatDateHuman(req.expiresAt, 'de')} abgelaufen.${msg ? `
${escapeHtml(msg).replace(/\n/g, '
')}
` : ''}

${req.source === 'lead' ? 'Lead öffnen & neu senden' : 'Geschäftspartner öffnen & neu senden'}

IP: ${escapeHtml(meta.ip)}

`, 'Automatische Benachrichtigung des LogShip-Signaturportals.' ) }) markSigningRequestRenewalRequested(req.token) }