|
|
|
|
@@ -7,6 +7,16 @@ import { VisitModel } from '@schema/metrics/VisitSchema';
|
|
|
|
|
import { EventModel } from '@schema/metrics/EventSchema';
|
|
|
|
|
import { ReportCustomizationModel, TReportCustomization } from '~/shared/schema/report/ReportCustomizationSchema';
|
|
|
|
|
|
|
|
|
|
import { getInstance } from '~/server/services/AiService';
|
|
|
|
|
|
|
|
|
|
import { zodResponseFormat } from 'openai/helpers/zod';
|
|
|
|
|
import z from 'zod';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const ZPromptResponse = z.object({
|
|
|
|
|
report: z.string({ description: 'Short, user-facing report summarizing website analytics data. Should be professional but slightly discursive, not just a list of stats, feel like a human summary — similar to an executive update. Highlight key numbers and insights (like visits, top countries, referrers). Use just text, no markdown. Max 620 chars.' }),
|
|
|
|
|
insights: z.string({ description: 'Growth hacker, product expert and marketing expert. Simple and effective actionable insights. Max 3. Short.' }).array()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
type PDFGenerationData = {
|
|
|
|
|
projectName: string,
|
|
|
|
|
@@ -18,8 +28,9 @@ type PDFGenerationData = {
|
|
|
|
|
topDevice: string,
|
|
|
|
|
topCountries: string[],
|
|
|
|
|
topReferrers: string[],
|
|
|
|
|
avgGrowthText: string,
|
|
|
|
|
customization?: TReportCustomization
|
|
|
|
|
customization?: any,
|
|
|
|
|
naturalText: string,
|
|
|
|
|
insights: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatNumberK(value: string | number, decimals: number = 1) {
|
|
|
|
|
@@ -37,10 +48,15 @@ const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : './.output/
|
|
|
|
|
|
|
|
|
|
function createPdf(data: PDFGenerationData) {
|
|
|
|
|
|
|
|
|
|
const pdf = new pdfkit({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, });
|
|
|
|
|
const pdf = new pdfkit({
|
|
|
|
|
size: 'A4',
|
|
|
|
|
margins: {
|
|
|
|
|
top: 30, bottom: 30, left: 50, right: 50
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let bgColor = '#0A0A0A';
|
|
|
|
|
let textColor = 'FFFFFF';
|
|
|
|
|
let textColor = '#FFFFFF';
|
|
|
|
|
let logo = data.customization?.logo ?? resourcePath + 'pdf_images/logo.png'
|
|
|
|
|
|
|
|
|
|
if (data.customization?.bg) {
|
|
|
|
|
@@ -54,49 +70,58 @@ function createPdf(data: PDFGenerationData) {
|
|
|
|
|
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor(textColor);
|
|
|
|
|
|
|
|
|
|
pdf.text(`${data.projectName}`, { align: 'center' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.text(`Report for: ${data.projectName} project`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
pdf.moveDown(LINE_SPACING)
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(13).fillColor(textColor);
|
|
|
|
|
pdf.text(`Timeframe name: ${data.snapshotName}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor(textColor)
|
|
|
|
|
|
|
|
|
|
pdf.text(`${data.naturalText}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
pdf.moveDown(LINE_SPACING)
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(13).fillColor(textColor);
|
|
|
|
|
pdf.text(`Plain metrics:`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor(textColor)
|
|
|
|
|
|
|
|
|
|
pdf.text(`Total visits: ${data.totalVisits}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.text(`Average visits per day: ${data.avgVisitsDay}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.text(`Total events: ${data.totalEvents}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.text(`Top domain: ${data.topDomain}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.text(`Top device: ${data.topDevice}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
pdf.text('Top 3 countries:', { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
data.topCountries.forEach((country: any) => {
|
|
|
|
|
pdf.text(`• ${country}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
});
|
|
|
|
|
pdf.text(`Top 3 countries: ${data.topCountries.join(', ')}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
pdf.text('Top 3 best acquisition channels (referrers):', { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
data.topReferrers.forEach((channel: any) => {
|
|
|
|
|
pdf.text(`• ${channel}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
});
|
|
|
|
|
pdf.text(`Top 3 best acquisition channels (referrers): ${data.topReferrers.join(', ')}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.text(`${data.avgGrowthText}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.moveDown(LINE_SPACING)
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(13).fillColor(textColor);
|
|
|
|
|
pdf.text(`Actionable insights:`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor(textColor)
|
|
|
|
|
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Italic.ttf')
|
|
|
|
|
.text('This gives you an idea of the average growth your website is experiencing over time.', { align: 'left' })
|
|
|
|
|
.moveDown(LINE_SPACING);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < data.insights.length; i++) {
|
|
|
|
|
pdf.text(`• ${data.insights[i]}`, { align: 'left' }).moveDown(LINE_SPACING);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf')
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
.fillColor(textColor)
|
|
|
|
|
.text('Created with Litlyx.com', 50, 760, { align: 'center' });
|
|
|
|
|
.text(`Created with Litlyx.com, ${new Date().toLocaleDateString('en-US')}`, 50, 780, { align: 'left' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pdf.image(logo, 460, 700, { width: 100 });
|
|
|
|
|
pdf.image(logo, 465, 695, { width: 85 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pdf.end();
|
|
|
|
|
return pdf;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default defineEventHandler(async event => {
|
|
|
|
|
|
|
|
|
|
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: true, requireRange: false });
|
|
|
|
|
const data = await getRequestData(event, [], []);
|
|
|
|
|
if (!data) return;
|
|
|
|
|
|
|
|
|
|
const userData = getRequestUser(event);
|
|
|
|
|
@@ -163,19 +188,36 @@ export default defineEventHandler(async event => {
|
|
|
|
|
|
|
|
|
|
const customization = await ReportCustomizationModel.findOne({ project_id: project._id });
|
|
|
|
|
|
|
|
|
|
const pdf = createPdf({
|
|
|
|
|
|
|
|
|
|
const textData: Omit<PDFGenerationData, 'naturalText' | 'insights' | 'customization'> = {
|
|
|
|
|
projectName: project.name,
|
|
|
|
|
snapshotName: snapshotHeader || 'NO_NAME',
|
|
|
|
|
totalVisits: formatNumberK(visitsCount),
|
|
|
|
|
avgVisitsDay: formatNumberK(avgVisitDay()) + '/day',
|
|
|
|
|
totalEvents: formatNumberK(eventsCount),
|
|
|
|
|
avgGrowthText: 'Insufficient Data (Requires at least 2 months of tracking)',
|
|
|
|
|
topDevice: topDevice,
|
|
|
|
|
topDomain: topDomain,
|
|
|
|
|
topCountries: topCountries.map(e => e._id),
|
|
|
|
|
topReferrers: topReferrers.map(e => e._id),
|
|
|
|
|
customization: customization?.toJSON() as TReportCustomization
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openai = getInstance();
|
|
|
|
|
|
|
|
|
|
const res = await openai.chat.completions.create({
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: 'user', content: `${JSON.stringify(textData)}`,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
response_format: zodResponseFormat(ZPromptResponse, 'response'),
|
|
|
|
|
model: 'gpt-4o-mini'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const resObject = JSON.parse(res.choices[0].message.content ?? '{}');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const pdf = createPdf({ ...textData, naturalText: resObject.report, insights: resObject.insights, customization: customization?.toJSON() as TReportCustomization, });
|
|
|
|
|
|
|
|
|
|
const passThrough = new PassThrough();
|
|
|
|
|
pdf.pipe(passThrough);
|
|
|
|
|
|