/** * Online quote confirmation — the offer counterpart of the contract signing * portal. Shared by quote/send.post.ts (link creation + mail CTA) and the * PUBLIC routes /api/public/confirm//* (page data, confirm, documents). * * Lifecycle: quote send with `confirmation.enabled` → createSigningRequest() * with kind 'quote' (contractSigningDb.ts — same table/store as contract * signing, superseding per kind) + the exact offer PDF on disk + a CTA button * in the quote e-mail → the lead opens /confirm/, reads the offer and * confirms with name/company/e-mail + declarations → finalizeQuoteConfirmation(): * the offer PDF gets a "Angebotsbestätigung" page appended (pdf-lib), is stored, * attached to the record (Strapi), written into offer_conditions.quote.confirmation, * mailed to the confirmer + info@logyou.de and logged as an activity. * * Public-route writes use the service token (config.api.idempieretoken). */ import { PDFDocument, StandardFonts, rgb } from 'pdf-lib' import { BRAND } from './quotePdf' import { attachToStrapi } from '../inbox/strapiAttach' import { patchOfferConditionsSection } from './offerConditions' import { APP_BASE_URL } from '../emailVerification' import { logContactActivity, strapiPublicFileUrl } from './contactActivity' import { NOTIFY_EMAIL, brandedEmail, escapeHtml, formatDateHuman, formatDateTimeHuman, mailTransport, recordLink } from './contractSigning' import { type SigningRequest, type SigningDocumentMeta, readSigningFile, writeSigningFile, sha256Hex, markSigningRequestSigned } from './contractSigningDb' export const confirmLink = (token: string) => `${APP_BASE_URL}/confirm/${token}` export const confirmDocumentLink = (token: string, key: string) => `${APP_BASE_URL}/api/public/confirm/${token}/document/${key}` /** Expiry of a confirmation link = the quote's validity (end of that day). */ export const resolveConfirmationExpiry = (validDays: number): string => { const n = Number.isFinite(validDays) && validDays > 0 ? Math.min(validDays, 365) : 7 const d = new Date() d.setDate(d.getDate() + n) d.setHours(23, 59, 59, 0) return d.toISOString() } /** CTA block inserted into the quote e-mail when online confirmation is on. */ export const confirmCtaHtml = (language: string, link: string, expiresAt: string) => { const en = language === 'en' const title = en ? 'View the quote online' : 'Angebot online ansehen' const text = en ? 'The button opens your quote for reading and download. Clicking it does not accept anything yet — if you decide to accept, you do so afterwards on that page in a separate, clearly marked step (no reply e-mail needed). You will then receive a confirmation copy immediately.' : 'Über den Button öffnen Sie Ihr Angebot zum Lesen und Herunterladen. Mit dem Klick bestätigen Sie noch nichts — wenn Sie das Angebot annehmen möchten, tun Sie das anschließend auf der Seite in einem separaten, klar gekennzeichneten Schritt (ganz ohne Antwort-E-Mail). Sie erhalten dann sofort eine Bestätigungskopie.' const btn = en ? 'Open quote' : 'Angebot öffnen' const note = en ? 'Viewing only — clicking does not accept the quote.' : '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 confirmCtaText = (language: string, link: string, expiresAt: string) => language === 'en' ? `\n\nView the quote online (accepting it there is optional and a separate step): ${link}\n(valid until ${formatDateHuman(expiresAt, 'en')})` : `\n\nAngebot online ansehen (die Annahme ist dort optional und ein separater Schritt): ${link}\n(gültig bis ${formatDateHuman(expiresAt, 'de')})` /** * Declarations the confirmer ticks — SERVER-side wording so the confirmation * page always records the canonical text. Never pre-selected. */ export const confirmationAcceptanceTexts = (language: string, req: SigningRequest) => { const en = language === 'en' const validDays = Number(req.payload?.meta?.validDays) || 7 const offer = req.documents.find((d) => d.key === 'offer') const name = offer?.filename || (en ? 'the quote' : 'das Angebot') return [ { key: 'offer', required: true, text: en ? `I have read the quote (${name}) and accept it with binding effect. The listed prices and conditions apply; the quote was valid for ${validDays} days.` : `Ich habe das Angebot (${name}) gelesen und nehme es verbindlich an. Es gelten die darin genannten Preise und Konditionen; das Angebot war ${validDays} Tage gültig.` }, { key: 'contract', required: true, text: en ? 'I understand that the fulfillment agreement and its annexes (T&C, data processing agreement) will follow for signature and that services start after the agreement is signed.' : 'Mir ist bekannt, dass der Fulfillment-Rahmenvertrag inkl. Anlagen (AGB, Auftragsverarbeitungsvertrag) zur Unterschrift folgt und die Leistung nach Vertragsunterzeichnung beginnt.' }, { key: 'authorized', required: true, text: en ? 'I am authorised to accept this quote on behalf of the named company.' : 'Ich bin berechtigt, dieses Angebot im Namen des genannten Unternehmens anzunehmen.' } ] } export interface ConfirmerInput { company: string contactName: string position?: string email: string phone?: string message?: string } export const validateConfirmer = (s: ConfirmerInput, 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 (!/.+@.+\..+/.test(String(s.email || '').trim())) return en ? 'A valid e-mail is required' : 'Eine gültige E-Mail-Adresse ist erforderlich' return '' } // pdf-lib standard fonts are WinAnsi — map the few typographic chars we use. const winAnsi = (s: any) => String(s ?? '') .replace(/→/g, '->').replace(/—/g, '-').replace(/–/g, '-') .replace(/…/g, '...').replace(/[‘’]/g, "'").replace(/[“”„]/g, '"') .replace(/[^\x00-\xFF€]/g, '?') const wrapText = (text: string, font: any, size: number, maxWidth: number): string[] => { const out: string[] = [] for (const para of String(text || '').split('\n')) { const words = para.split(/\s+/).filter(Boolean) if (!words.length) { out.push(''); continue } let line = '' for (const w of words) { const probe = line ? `${line} ${w}` : w if (font.widthOfTextAtSize(probe, size) <= maxWidth) { line = probe; continue } if (line) out.push(line) line = w } if (line) out.push(line) } return out } /** * The "confirmed offer": the exact offer PDF the customer saw + one appended * confirmation page (who, when, from where, hash of the offer, declarations). */ export const buildConfirmedOfferPdf = async (offerBuffer: Buffer, opts: { language: string offerFilename: string offerSha256: string confirmer: ConfirmerInput confirmedAt: string ip: string userAgent: string token: string acceptances: Array<{ key: string; text: string; acceptedAt: string }> }): Promise => { const en = opts.language === 'en' const pdf = await PDFDocument.load(offerBuffer, { ignoreEncryption: true }) const font = await pdf.embedFont(StandardFonts.Helvetica) const bold = await pdf.embedFont(StandardFonts.HelveticaBold) const page = pdf.addPage([595.28, 841.89]) const navy = rgb(0x1c / 255, 0x32 / 255, 0x4b / 255) const orange = rgb(0xf1 / 255, 0x59 / 255, 0x24 / 255) const grey = rgb(0x5a / 255, 0x6b / 255, 0x7c / 255) const text = rgb(0x33 / 255, 0x41 / 255, 0x4e / 255) const M = 48 const W = 595.28 - 2 * M let y = 841.89 - 60 page.drawRectangle({ x: 0, y: 841.89 - 22, width: 595.28, height: 22, color: navy }) page.drawRectangle({ x: 0, y: 841.89 - 25, width: 595.28, height: 3, color: orange }) page.drawText(winAnsi(en ? 'Quote acceptance' : 'Angebotsbestätigung'), { x: M, y, size: 20, font: bold, color: navy }) y -= 18 page.drawText(winAnsi(en ? 'Binding acceptance via the LogYou online portal' : 'Verbindliche Annahme über das LogYou Online-Portal'), { x: M, y, size: 10, font, color: grey }) y -= 30 const rows: Array<[string, string]> = [ [en ? 'Quote' : 'Angebot', opts.offerFilename], [en ? 'Company' : 'Firma', opts.confirmer.company], [en ? 'Accepted by' : 'Angenommen von', `${opts.confirmer.contactName}${opts.confirmer.position ? `, ${opts.confirmer.position}` : ''}`], [en ? 'E-mail' : 'E-Mail', opts.confirmer.email], [en ? 'Phone' : 'Telefon', opts.confirmer.phone || '-'], [en ? 'Accepted on' : 'Angenommen am', formatDateTimeHuman(opts.confirmedAt, opts.language)], [en ? 'IP address' : 'IP-Adresse', opts.ip], [en ? 'Browser' : 'Browser', opts.userAgent.slice(0, 90)], [en ? 'Link token' : 'Link-Token', opts.token.slice(0, 16) + '…'], [en ? 'SHA-256 (quote)' : 'SHA-256 (Angebot)', opts.offerSha256] ] for (const [k, v] of rows) { page.drawText(winAnsi(k), { x: M, y, size: 9.5, font: bold, color: text }) const lines = wrapText(winAnsi(v), font, 9.5, W - 150) for (const ln of lines) { page.drawText(ln, { x: M + 150, y, size: 9.5, font, color: text }) y -= 13 } y -= 3 } if (opts.confirmer.message) { y -= 8 page.drawText(winAnsi(en ? 'Message from the customer' : 'Nachricht des Kunden'), { x: M, y, size: 10.5, font: bold, color: navy }) y -= 15 for (const ln of wrapText(winAnsi(opts.confirmer.message), font, 9.5, W)) { page.drawText(ln, { x: M, y, size: 9.5, font, color: text }); y -= 13 } } y -= 12 page.drawText(winAnsi(en ? 'Declarations confirmed' : 'Bestätigte Erklärungen'), { x: M, y, size: 10.5, font: bold, color: navy }) y -= 15 for (const a of opts.acceptances) { const lines = wrapText(winAnsi(a.text), font, 9, W - 14) page.drawText('x', { x: M, y: y + 1, size: 8, font: bold, color: orange }) for (const ln of lines) { page.drawText(ln, { x: M + 14, y, size: 9, font, color: text }); y -= 12 } page.drawText(winAnsi(`${en ? 'confirmed at' : 'bestätigt am'} ${formatDateTimeHuman(a.acceptedAt, opts.language)}`), { x: M + 14, y, size: 7.5, font, color: grey }) y -= 16 } page.drawText(winAnsi(en ? 'This page was generated automatically by the LogYou quote portal and forms part of the accepted quote.' : 'Diese Seite wurde automatisch vom LogYou Angebotsportal erzeugt und ist Bestandteil des angenommenen Angebots.'), { x: M, y: 40, size: 7.5, font, color: grey }) return Buffer.from(await pdf.save()) } 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[] /** * Confirm: build the confirmed offer PDF, store + attach + notify + log. * Throws only when the confirmed PDF cannot be produced/stored — everything * downstream is fail-soft and reported in `warnings`. */ export const finalizeQuoteConfirmation = async (event: any, req: SigningRequest, confirmer: ConfirmerInput, meta: { ip: string; userAgent: string }, acceptances: Array<{ key: string; text: string; acceptedAt: string }> = []) => { const warnings: string[] = [] const language = req.language const en = language === 'en' const confirmedAt = new Date().toISOString() const offerMeta = req.documents.find((d) => d.key === 'offer') || req.documents[0] if (!offerMeta) throw new Error('no offer document on request') const offerBuffer = readSigningFile(req.token, offerMeta.filename) if (!offerBuffer) throw new Error('offer file missing') const confirmedFilename = offerMeta.filename.replace(/\.pdf$/i, en ? '_confirmed.pdf' : '_bestaetigt.pdf') const confirmedBuffer = await buildConfirmedOfferPdf(offerBuffer, { language, offerFilename: offerMeta.filename, offerSha256: offerMeta.sha256, confirmer, confirmedAt, ip: meta.ip, userAgent: meta.userAgent, token: req.token, acceptances }) writeSigningFile(req.token, confirmedFilename, confirmedBuffer) const confirmedMeta: SigningDocumentMeta = { key: 'confirmed', title: en ? 'Accepted quote (confirmation)' : 'Angenommenes Angebot (Bestätigung)', filename: confirmedFilename, sha256: sha256Hex(confirmedBuffer), size: confirmedBuffer.length, signed: true } const confirmedDocuments: SigningDocumentMeta[] = [confirmedMeta, ...req.documents] const confirmerRecord = { ...confirmer, confirmedAt, ip: meta.ip, userAgent: meta.userAgent, acceptances } markSigningRequestSigned(req.token, confirmerRecord, confirmedDocuments, confirmedAt) const config: any = useRuntimeConfig() const serviceToken: string = config.api?.idempieretoken || '' // 1) Attach the confirmed offer to the record. 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: confirmedBuffer, filename: confirmedFilename, mimeType: 'application/pdf' }) attachedUrl = strapiPublicFileUrl(up.fileUrl) } catch (err: any) { console.error('[Confirm] attach confirmed offer failed:', err?.message || err) warnings.push('attach') } // 2) Persist the confirmation on the record's quote conditions. try { if (!(req.recordId > 0)) throw new Error('no record id') await patchOfferConditionsSection(event, serviceToken, req.source, req.recordId, 'quote', { confirmation: { confirmedAt, name: confirmer.contactName, company: confirmer.company, position: confirmer.position || '', email: confirmer.email, phone: confirmer.phone || '', message: confirmer.message || '', filename: confirmedFilename, offerFilename: offerMeta.filename, offerSha256: offerMeta.sha256, token: req.token, ip: meta.ip, acceptances } }) } catch (err: any) { console.error('[Confirm] offer_conditions save skipped:', err?.message || err) warnings.push('conditions') } // 3) Activity on the record (fail-soft inside the helper). try { const docs = [ { title: confirmedMeta.title, url: attachedUrl || confirmDocumentLink(req.token, 'confirmed') }, { title: en ? 'Quote (as sent)' : 'Angebot (wie versendet)', url: confirmDocumentLink(req.token, 'offer') } ] const created = await logContactActivity(event, serviceToken, { source: req.source, recordId: req.recordId, type: 'EM', contactEmail: req.email, description: en ? `Quote accepted online: ${confirmer.company}` : `Angebot online bestätigt: ${confirmer.company}`, comments: en ? `${confirmer.contactName}${confirmer.position ? ` (${confirmer.position})` : ''}, ${confirmer.company}, accepted the quote ${offerMeta.filename} on ${formatDateTimeHuman(confirmedAt, 'en')} via the online portal.\nE-mail: ${confirmer.email}${confirmer.phone ? ` · Phone: ${confirmer.phone}` : ''}${confirmer.message ? `\n\nMessage: ${confirmer.message}` : ''}` : `${confirmer.contactName}${confirmer.position ? ` (${confirmer.position})` : ''}, ${confirmer.company}, hat das Angebot ${offerMeta.filename} am ${formatDateTimeHuman(confirmedAt, 'de')} über das Online-Portal verbindlich angenommen.\nE-Mail: ${confirmer.email}${confirmer.phone ? ` · Telefon: ${confirmer.phone}` : ''}${confirmer.message ? `\n\nNachricht: ${confirmer.message}` : ''}`, documents: docs }) if (!created) warnings.push('activity') } catch (err: any) { console.error('[Confirm] activity failed:', err?.message || err) warnings.push('activity') } // 4) Confirmation copy to the customer. const attachments = attachmentsFor(req, confirmedDocuments) try { await mailTransport().sendMail({ from: NOTIFY_EMAIL, to: confirmer.email, subject: en ? `Your accepted quote – ${confirmer.company}` : `Ihr angenommenes Angebot – ${confirmer.company}`, text: en ? `Dear ${confirmer.contactName},\n\nthank you — you accepted our quote on ${formatDateTimeHuman(confirmedAt, 'en')}. The quote and your confirmation are attached.\n\nNext step: we will send you the fulfillment agreement for digital signature and get in touch within one business day to plan your onboarding.\n\nBest regards\nYour LogYou team` : `Guten Tag ${confirmer.contactName},\n\nvielen Dank — Sie haben unser Angebot am ${formatDateTimeHuman(confirmedAt, 'de')} verbindlich angenommen. Das Angebot und Ihre Bestätigung finden Sie im Anhang.\n\nNächster Schritt: Wir senden Ihnen den Fulfillment-Rahmenvertrag zur digitalen Unterschrift und melden uns innerhalb von einem Werktag, um Ihr Onboarding zu planen.\n\nMit freundlichen Grüßen\nIhr LogYou Team`, html: brandedEmail( en ? 'Your accepted quote' : 'Ihr angenommenes Angebot', en ? `Dear ${escapeHtml(confirmer.contactName)},

thank you — you accepted our quote on ${formatDateTimeHuman(confirmedAt, 'en')}. The quote and your confirmation are attached to this e-mail.

Next step: we will send you the fulfillment agreement for digital signature and get in touch within one business day to plan your onboarding.

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

vielen Dank — Sie haben unser Angebot am ${formatDateTimeHuman(confirmedAt, 'de')} verbindlich angenommen. Das Angebot und Ihre Bestätigung finden Sie im Anhang dieser E-Mail.

Nächster Schritt: Wir senden Ihnen den Fulfillment-Rahmenvertrag zur digitalen Unterschrift und melden uns innerhalb von einem Werktag, um Ihr Onboarding zu planen.

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('[Confirm] customer copy mail failed:', err?.message || err) warnings.push('mail-customer') } // 5) Internal notification with record link. try { const link = recordLink(req.source, req.recordId) const rows = [ ['Firma', confirmer.company], ['Name', confirmer.contactName], ['Position', confirmer.position || '—'], ['E-Mail', confirmer.email], ['Telefon', confirmer.phone || '—'], ['Angebot', offerMeta.filename], ['Bestätigt am', formatDateTimeHuman(confirmedAt, '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: `✅ Angebot online bestätigt – ${confirmer.company}`, text: `${confirmer.company} (${confirmer.contactName}) hat das Angebot ${offerMeta.filename} am ${formatDateTimeHuman(confirmedAt, 'de')} online verbindlich angenommen.${confirmer.message ? `\n\nNachricht: ${confirmer.message}` : ''}\n\nDatensatz: ${link}`, html: brandedEmail( 'Angebot online bestätigt', `${escapeHtml(confirmer.company)} (${escapeHtml(confirmer.contactName)}) hat das Angebot verbindlich angenommen.${table}${confirmer.message ? `
${escapeHtml(confirmer.message).replace(/\n/g, '
')}
` : ''}Die Bestätigung ist angehängt und wurde am ${req.source === 'lead' ? 'Lead' : 'Geschäftspartner'} als Anhang und Aktivität gespeichert. Nächster Schritt: Vertrag senden.

${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-Angebotsportals.' ), attachments }) } catch (err: any) { console.error('[Confirm] internal notification mail failed:', err?.message || err) warnings.push('mail-internal') } return { confirmedAt, confirmedDocuments, confirmedFilename, warnings } }