mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-11 16:28:37 +01:00
new selfhosted version
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], ['OWNER']);
|
||||
if (!data) return;
|
||||
|
||||
const { project } = data;
|
||||
|
||||
const { name } = await readBody(event);
|
||||
|
||||
if (name.trim().length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||
if (name.trim().length < 2) return setResponseStatus(event, 400, 'name too short');
|
||||
if (name.trim().length > 32) return setResponseStatus(event, 400, 'name too long');
|
||||
|
||||
project.name = name.trim();
|
||||
await project.save();
|
||||
|
||||
return { ok: true };
|
||||
|
||||
});
|
||||
21
dashboard/server/api/project/change_name.ts
Normal file
21
dashboard/server/api/project/change_name.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ProjectModel } from "~/shared/schema/project/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const ctx = await getRequestContext(event, 'pid');
|
||||
|
||||
const { project_id } = ctx;
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const name = body.name.trim();
|
||||
|
||||
if (name.trim().length == 0) throw createError({ status: 400, message: 'Name is required' });
|
||||
if (name.trim().length <= 2) throw createError({ status: 400, message: 'Name too short' });
|
||||
if (name.trim().length >= 24) throw createError({ status: 400, message: 'Name too long' });
|
||||
|
||||
await ProjectModel.updateOne({ _id: project_id }, { name });
|
||||
|
||||
return { ok: true };
|
||||
|
||||
});
|
||||
@@ -1,88 +1,31 @@
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
const ctx = await getRequestContext(event);
|
||||
|
||||
const newProjectName = body.name.trim();
|
||||
const { name } = await readBody(event);
|
||||
|
||||
if (!newProjectName) return setResponseStatus(event, 400, 'ProjectName too short');
|
||||
if (newProjectName.length < 2) return setResponseStatus(event, 400, 'ProjectName too short');
|
||||
const newProjectName = name.trim();
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const maxProjects = 20;
|
||||
|
||||
const existingUserProjects = await ProjectModel.countDocuments({ owner: userData.id });
|
||||
if (existingUserProjects >= maxProjects) return setResponseStatus(event, 400, 'Already have max number of projects');
|
||||
|
||||
if (StripeService.isDisabled()) {
|
||||
|
||||
const project = await ProjectModel.create({
|
||||
owner: userData.id,
|
||||
name: newProjectName,
|
||||
premium: false,
|
||||
premium_type: 0,
|
||||
customer_id: 'DISABLED_MODE',
|
||||
subscription_id: "DISABLED_MODE",
|
||||
premium_expire_at: new Date(3000, 1, 1)
|
||||
});
|
||||
if (!newProjectName) throw createError({ status: 400, message: 'name is required' });
|
||||
if (newProjectName.length <= 2) throw createError({ status: 400, message: 'Workspace name too short' });
|
||||
if (newProjectName.length >= 24) throw createError({ status: 400, message: 'Workspace name too long' });
|
||||
|
||||
|
||||
await ProjectCountModel.create({
|
||||
project_id: project._id,
|
||||
events: 0,
|
||||
visits: 0,
|
||||
sessions: 0
|
||||
});
|
||||
const plan = await getPlanInfoFromUserId(ctx.user_id);
|
||||
if (!plan) return setResponseStatus(event, 400, 'Plan not found. Please contact support');
|
||||
|
||||
await ProjectLimitModel.updateOne({ project_id: project._id }, {
|
||||
events: 0,
|
||||
visits: 0,
|
||||
ai_messages: 0,
|
||||
limit: 10_000_000,
|
||||
ai_limit: 1_000_000,
|
||||
billing_start_at: Date.now(),
|
||||
billing_expire_at: new Date(3000, 1, 1)
|
||||
}, { upsert: true })
|
||||
const maxProjects = plan.features.workspaces;
|
||||
|
||||
return project.toJSON() as TProject;
|
||||
const existingUserProjects = await ProjectModel.countDocuments({ owner: ctx.user_id });
|
||||
if (existingUserProjects >= maxProjects) throw createError({ status: 400, message: 'Workspace limit reached.', statusMessage: 'WORKSPACE_LIMIT_REACHED' });
|
||||
|
||||
} else {
|
||||
|
||||
const customer = await StripeService.createCustomer(userData.user.email);
|
||||
if (!customer) return setResponseStatus(event, 400, 'Error creating customer');
|
||||
|
||||
const subscription = await StripeService.createFreeSubscription(customer.id);
|
||||
if (!subscription) return setResponseStatus(event, 400, 'Error creating subscription');
|
||||
|
||||
const project = await ProjectModel.create({
|
||||
owner: userData.id,
|
||||
name: newProjectName,
|
||||
premium: false,
|
||||
premium_type: 0,
|
||||
customer_id: customer.id,
|
||||
subscription_id: subscription.id,
|
||||
premium_expire_at: subscription.current_period_end * 1000
|
||||
});
|
||||
|
||||
|
||||
await ProjectCountModel.create({
|
||||
project_id: project._id,
|
||||
events: 0,
|
||||
visits: 0,
|
||||
sessions: 0
|
||||
});
|
||||
|
||||
return project.toJSON() as TProject;
|
||||
|
||||
}
|
||||
const project = await ProjectModel.create({ owner: ctx.user_id, name: newProjectName });
|
||||
|
||||
await ProjectCountModel.create({ project_id: project._id, events: 0, visits: 0, sessions: 0 });
|
||||
|
||||
return project.toJSON() as TProject;
|
||||
|
||||
});
|
||||
@@ -1,43 +1,41 @@
|
||||
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { AddressBlacklistModel } from "~/shared/schema/shields/AddressBlacklistSchema";
|
||||
import { DomainWhitelistModel } from "~/shared/schema/shields/DomainWhitelistSchema";
|
||||
import { CountryBlacklistModel } from "~/shared/schema/shields/CountryBlacklistSchema";
|
||||
import { BotTrafficOptionModel } from "~/shared/schema/shields/BotTrafficOptionSchema";
|
||||
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
|
||||
import { EventModel } from "~/shared/schema/metrics/EventSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
|
||||
if (!data) return;
|
||||
const ctx = await getRequestContext(event, 'pid');
|
||||
|
||||
const { project, user, project_id } = data;
|
||||
const { project_id } = ctx;
|
||||
|
||||
const projects = await ProjectModel.countDocuments({ owner: user.id });
|
||||
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');
|
||||
|
||||
if (project.premium === true) return setResponseStatus(event, 400, 'Cannot delete premium project');
|
||||
|
||||
if (project.customer_id) {
|
||||
await StripeService.deleteCustomer(project.customer_id);
|
||||
}
|
||||
|
||||
const projectDeletation = ProjectModel.deleteOne({ _id: project_id });
|
||||
const projectDeletation = await ProjectModel.deleteOne({ _id: project_id });
|
||||
const userSettingsDeletation = await UserSettingsModel.deleteOne({ project_id });
|
||||
|
||||
const countDeletation = ProjectCountModel.deleteMany({ project_id });
|
||||
const limitdeletation = ProjectLimitModel.deleteMany({ project_id });
|
||||
|
||||
const sessionsDeletation = SessionModel.deleteMany({ project_id });
|
||||
const notifiesDeletation = LimitNotifyModel.deleteMany({ project_id });
|
||||
const visitsDeletation = VisitModel.deleteMany({ project_id });
|
||||
const eventsDeletation = EventModel.deleteMany({ project_id });
|
||||
|
||||
const aiChatsDeletation = AiChatModel.deleteMany({ project_id });
|
||||
|
||||
const results = await Promise.all([
|
||||
projectDeletation,
|
||||
countDeletation,
|
||||
limitdeletation,
|
||||
sessionsDeletation,
|
||||
notifiesDeletation,
|
||||
aiChatsDeletation
|
||||
])
|
||||
//Shields
|
||||
const addressBlacklistDeletation = AddressBlacklistModel.deleteMany({ project_id });
|
||||
const botTrafficOptionsDeletation = BotTrafficOptionModel.deleteMany({ project_id });
|
||||
const countryBlacklistDeletation = CountryBlacklistModel.deleteMany({ project_id });
|
||||
const domainWhitelistDeletation = DomainWhitelistModel.deleteMany({ project_id });
|
||||
|
||||
|
||||
return { ok: true };
|
||||
|
||||
return { data: results };
|
||||
|
||||
});
|
||||
@@ -1,20 +1,16 @@
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { EventModel } from "~/shared/schema/metrics/EventSchema";
|
||||
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestDataOld(event, {
|
||||
requireSchema: false,
|
||||
allowLitlyx: false,
|
||||
requireSlice: false
|
||||
});
|
||||
if (!data) return;
|
||||
const ctx = await getRequestContext(event, 'pid', 'permission:webAnalytics');
|
||||
|
||||
const { project_id } = data;
|
||||
const hasEvent = await EventModel.exists({ project_id });
|
||||
if (hasEvent) return true;
|
||||
const hasVisit = await VisitModel.exists({ project_id });
|
||||
if (hasVisit) return true;
|
||||
const { project_id } = ctx;
|
||||
|
||||
const hasEvents = await EventModel.exists({ project_id });
|
||||
if (hasEvents) return true;
|
||||
const hasVisits = await VisitModel.exists({ project_id });
|
||||
if (hasVisits) return true;
|
||||
|
||||
return false;
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
|
||||
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { EventModel } from "~/shared/schema/metrics/EventSchema";
|
||||
|
||||
const { SELFHOSTED } = useRuntimeConfig();
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestDataOld(event, { requireSchema: false });
|
||||
if (!data) return;
|
||||
|
||||
const { project, project_id, user } = data;
|
||||
|
||||
|
||||
if (SELFHOSTED.toString() !== 'TRUE' && SELFHOSTED.toString() !== 'true') {
|
||||
const PREMIUM_TYPE = project.premium_type;
|
||||
if (PREMIUM_TYPE === 0) return setResponseStatus(event, 400, 'Project not premium');
|
||||
}
|
||||
|
||||
|
||||
const { mode, slice } = getQuery(event);
|
||||
|
||||
let timeSub = 1000 * 60 * 60 * 24;
|
||||
|
||||
if (slice == '0') {
|
||||
timeSub = 1000 * 60 * 60 * 24
|
||||
} else if (slice == '1') {
|
||||
timeSub = 1000 * 60 * 60 * 24 * 7
|
||||
} else if (slice == '2') {
|
||||
timeSub = 1000 * 60 * 60 * 24 * 7 * 30
|
||||
} else if (slice == '3') {
|
||||
timeSub = 1000 * 60 * 60 * 24 * 7 * 30 * 12 * 2
|
||||
}
|
||||
|
||||
if (mode === 'visits') {
|
||||
|
||||
const visistsReportData = await VisitModel.find({
|
||||
project_id,
|
||||
created_at: {
|
||||
$gt: Date.now() - timeSub
|
||||
}
|
||||
});
|
||||
|
||||
const csvHeader = [
|
||||
"browser",
|
||||
"os",
|
||||
"continent",
|
||||
"country",
|
||||
"device",
|
||||
"website",
|
||||
"page",
|
||||
"referrer",
|
||||
"created_at",
|
||||
];
|
||||
|
||||
|
||||
const lines: any[] = [];
|
||||
visistsReportData.forEach(line => lines.push(line.toJSON()));
|
||||
|
||||
const result = csvHeader.join(',') + '\n' + lines.map(element => {
|
||||
const content: string[] = [];
|
||||
for (const key of csvHeader) {
|
||||
content.push(element[key]);
|
||||
}
|
||||
return content.join(',');
|
||||
}).join('\n');
|
||||
|
||||
|
||||
return result;
|
||||
} else if (mode === 'events') {
|
||||
|
||||
|
||||
const eventsReportData = await EventModel.find({
|
||||
project_id,
|
||||
created_at: {
|
||||
$gt: Date.now() - timeSub
|
||||
}
|
||||
});
|
||||
|
||||
const csvHeader = [
|
||||
"name",
|
||||
"session",
|
||||
"metadata",
|
||||
"website",
|
||||
"created_at",
|
||||
];
|
||||
|
||||
|
||||
const lines: any[] = [];
|
||||
eventsReportData.forEach(line => lines.push(line.toJSON()));
|
||||
|
||||
const result = csvHeader.join(',') + '\n' + lines.map(element => {
|
||||
const content: string[] = [];
|
||||
for (const key of csvHeader) {
|
||||
content.push(element[key]);
|
||||
}
|
||||
return content.join(',');
|
||||
}).join('\n');
|
||||
|
||||
|
||||
return result;
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
223
dashboard/server/api/project/generate_pdf.post.ts
Normal file
223
dashboard/server/api/project/generate_pdf.post.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
|
||||
import pdfkit from 'pdfkit';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
|
||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||
import z from 'zod';
|
||||
import { AiService } from '~/server/services/ai/AiService';
|
||||
|
||||
|
||||
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,
|
||||
snapshotName: string,
|
||||
totalVisits: string,
|
||||
avgVisitsDay: string,
|
||||
totalEvents: string,
|
||||
topDomain: string,
|
||||
topDevice: string,
|
||||
topCountries: string[],
|
||||
topReferrers: string[],
|
||||
customization: { theme: string, logo?: string },
|
||||
naturalText: string,
|
||||
insights: string[]
|
||||
}
|
||||
|
||||
function formatNumberK(value: string | number, decimals: number = 1) {
|
||||
const num = parseInt(value.toString());
|
||||
|
||||
if (num > 1_000_000) return (num / 1_000_000).toFixed(decimals) + ' M';
|
||||
if (num > 1_000) return (num / 1_000).toFixed(decimals) + ' K';
|
||||
return num.toFixed();
|
||||
|
||||
}
|
||||
|
||||
const LINE_SPACING = 0.5;
|
||||
|
||||
function getResourcePath() {
|
||||
if (isSelfhosted()) return '/home/app/public/pdf/';
|
||||
return process.dev ? './public/pdf/' : './.output/public/pdf/';
|
||||
}
|
||||
|
||||
const resourcePath = getResourcePath();
|
||||
|
||||
function createPdf(data: PDFGenerationData) {
|
||||
|
||||
const pdf = new pdfkit({
|
||||
size: 'A4',
|
||||
margins: {
|
||||
top: 30, bottom: 30, left: 50, right: 50
|
||||
},
|
||||
});
|
||||
|
||||
let bgColor = '#0A0A0A';
|
||||
let textColor = '#FFFFFF';
|
||||
let logo = data.customization.logo ? Buffer.from(data.customization.logo.split(',')[1], 'base64') :
|
||||
(data.customization.theme === 'white' ? (resourcePath + 'pdf_images/logo-black.png') : (resourcePath + 'pdf_images/logo-white.png'))
|
||||
|
||||
bgColor = data.customization.theme === 'white' ? '#FFFFFF' : '#0A0A0A';
|
||||
textColor = data.customization.theme === 'white' ? '#000000' : '#FFFFFF';
|
||||
|
||||
pdf.fillColor(bgColor).rect(0, 0, pdf.page.width, pdf.page.height).fill(bgColor);
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor(textColor);
|
||||
|
||||
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: ${data.topCountries.join(', ')}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
pdf.text(`Top 3 best acquisition channels (referrers): ${data.topReferrers.join(', ')}`, { 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)
|
||||
|
||||
|
||||
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, ${new Date().toLocaleDateString('en-US')}`, 50, 780, { align: 'left' });
|
||||
|
||||
|
||||
pdf.image(logo, 465, 695, { width: 85 });
|
||||
|
||||
|
||||
pdf.end();
|
||||
return pdf;
|
||||
}
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const ctx = await getRequestContext(event, 'pid', 'range');
|
||||
|
||||
const { from, to } = ctx;
|
||||
|
||||
const { theme } = getQuery(event);
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const project = await ProjectModel.findById(ctx.project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
const eventsCount = await EventModel.countDocuments({
|
||||
project_id: project._id,
|
||||
created_at: { $gte: new Date(from), $lte: new Date(to) }
|
||||
});
|
||||
|
||||
const visitsCount = await VisitModel.countDocuments({
|
||||
project_id: project._id,
|
||||
created_at: { $gte: new Date(from), $lte: new Date(to) }
|
||||
});
|
||||
|
||||
const avgVisitDay = () => {
|
||||
const days = (Date.now() - (from)) / 1000 / 60 / 60 / 24;
|
||||
const avg = visitsCount / Math.max(days, 1);
|
||||
return avg;
|
||||
};
|
||||
|
||||
const topDevices = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: new Date(from), $lte: new Date(to) } } },
|
||||
{ $group: { _id: "$device", count: { $sum: 1 } } },
|
||||
{ $match: { _id: { $ne: null } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 1 }
|
||||
]);
|
||||
|
||||
const topDevice = topDevices?.[0]?._id || 'Not enough data';
|
||||
|
||||
const topDomains = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: new Date(from), $lte: new Date(to) } } },
|
||||
{ $group: { _id: "$website", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 1 }
|
||||
]);
|
||||
|
||||
const topDomain = topDomains?.[0]?._id || 'Not enough data';
|
||||
|
||||
const topCountries = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: new Date(from), $lte: new Date(to) } } },
|
||||
{ $group: { _id: "$country", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]);
|
||||
|
||||
const topReferrers = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: new Date(from), $lte: new Date(to) } } },
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]);
|
||||
|
||||
const textData: Omit<PDFGenerationData, 'naturalText' | 'insights' | 'customization'> = {
|
||||
projectName: project.name,
|
||||
snapshotName: body.snapshotName || 'NO_NAME',
|
||||
totalVisits: formatNumberK(visitsCount),
|
||||
avgVisitsDay: formatNumberK(avgVisitDay()) + '/day',
|
||||
totalEvents: formatNumberK(eventsCount),
|
||||
topDevice: topDevice,
|
||||
topDomain: topDomain,
|
||||
topCountries: topCountries.map(e => e._id),
|
||||
topReferrers: topReferrers.map(e => e._id),
|
||||
}
|
||||
|
||||
const openai = AiService.init();
|
||||
|
||||
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 resObject = { report: '', insights: [''] };
|
||||
|
||||
const pdf = createPdf({
|
||||
...textData, naturalText: resObject.report, insights: resObject.insights,
|
||||
customization: {
|
||||
theme: (theme ?? 'black') as string,
|
||||
logo: body.customLogo
|
||||
},
|
||||
});
|
||||
|
||||
const passThrough = new PassThrough();
|
||||
pdf.pipe(passThrough);
|
||||
await sendStream(event, passThrough);
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
|
||||
import pdfkit from 'pdfkit';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
|
||||
|
||||
type PDFGenerationData = {
|
||||
projectName: string,
|
||||
snapshotName: string,
|
||||
totalVisits: string,
|
||||
avgVisitsDay: string,
|
||||
totalEvents: string,
|
||||
topDomain: string,
|
||||
topDevice: string,
|
||||
topCountries: string[],
|
||||
topReferrers: string[],
|
||||
avgGrowthText: string,
|
||||
|
||||
}
|
||||
|
||||
function formatNumberK(value: string | number, decimals: number = 1) {
|
||||
const num = parseInt(value.toString());
|
||||
|
||||
if (num > 1_000_000) return (num / 1_000_000).toFixed(decimals) + ' M';
|
||||
if (num > 1_000) return (num / 1_000).toFixed(decimals) + ' K';
|
||||
return num.toFixed();
|
||||
|
||||
}
|
||||
|
||||
const LINE_SPACING = 0.5;
|
||||
|
||||
const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : './.output/public/pdf/';
|
||||
|
||||
function createPdf(data: PDFGenerationData) {
|
||||
|
||||
const pdf = new pdfkit({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, });
|
||||
pdf.fillColor('#ffffff').rect(0, 0, pdf.page.width, pdf.page.height).fill('#000000');
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor('#ffffff');
|
||||
|
||||
pdf.text(`Project name: ${data.projectName}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
pdf.text(`Timeframe name: ${data.snapshotName}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor('#ffffff')
|
||||
|
||||
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 best acquisition channels (referrers):', { align: 'left' }).moveDown(LINE_SPACING);
|
||||
data.topReferrers.forEach((channel: any) => {
|
||||
pdf.text(`• ${channel}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
});
|
||||
|
||||
pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
|
||||
pdf.text(`${data.avgGrowthText}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||
|
||||
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);
|
||||
|
||||
pdf.font(resourcePath + 'pdf_fonts/Poppins-Regular.ttf')
|
||||
.fontSize(10)
|
||||
.fillColor('#ffffff')
|
||||
.text('Created with Litlyx.com', 50, 760, { align: 'center' });
|
||||
|
||||
pdf.image(resourcePath + 'pdf_images/logo.png', 460, 700, { width: 100 });
|
||||
|
||||
pdf.end();
|
||||
return pdf;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: true, requireRange: false });
|
||||
if (!data) return;
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const project = await ProjectModel.findById(data.project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
const snapshotHeader = getHeader(event, 'x-snapshot-name');
|
||||
const fromHeader = getHeader(event, 'x-from');
|
||||
const toHeader = getHeader(event, 'x-to');
|
||||
|
||||
const from = fromHeader ? new Date(fromHeader) : new Date(2020, 0);
|
||||
const to = toHeader ? new Date(toHeader) : new Date(3001, 0);
|
||||
|
||||
const eventsCount = await EventModel.countDocuments({
|
||||
project_id: project._id,
|
||||
created_at: { $gte: from, $lte: to }
|
||||
});
|
||||
|
||||
const visitsCount = await VisitModel.countDocuments({
|
||||
project_id: project._id,
|
||||
created_at: { $gte: from, $lte: to }
|
||||
});
|
||||
|
||||
const avgVisitDay = () => {
|
||||
const days = (Date.now() - (from.getTime())) / 1000 / 60 / 60 / 24;
|
||||
const avg = visitsCount / Math.max(days, 1);
|
||||
return avg;
|
||||
};
|
||||
|
||||
const topDevices = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||
{ $group: { _id: "$device", count: { $sum: 1 } } },
|
||||
{ $match: { _id: { $ne: null } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 1 }
|
||||
]);
|
||||
|
||||
const topDevice = topDevices?.[0]?._id || 'Not enough data';
|
||||
|
||||
const topDomains = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||
{ $group: { _id: "$website", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 1 }
|
||||
]);
|
||||
|
||||
const topDomain = topDomains?.[0]?._id || 'Not enough data';
|
||||
|
||||
const topCountries = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||
{ $group: { _id: "$country", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]);
|
||||
|
||||
const topReferrers = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]);
|
||||
|
||||
const pdf = createPdf({
|
||||
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)
|
||||
});
|
||||
|
||||
const passThrough = new PassThrough();
|
||||
pdf.pipe(passThrough);
|
||||
await sendStream(event, passThrough);
|
||||
});
|
||||
865
dashboard/server/api/project/generate_pdf_adv.ts
Normal file
865
dashboard/server/api/project/generate_pdf_adv.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
import { ProjectModel, TProject } from "~/shared/schema/project/ProjectSchema";
|
||||
import puppeteer from 'puppeteer'
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import fs from 'fs';
|
||||
import path from "path";
|
||||
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
|
||||
import { formatNumberK, formatTime } from "~/utils/numberFormatter";
|
||||
import { visitController } from "~/server/controllers/VisitController";
|
||||
import { durationController } from "~/server/controllers/DurationController";
|
||||
import { bouncingController } from "~/server/controllers/BouncingController";
|
||||
import { AiService } from "~/server/services/ai/AiService";
|
||||
import referrers from "../data/referrers";
|
||||
|
||||
|
||||
type BaseOptions = {
|
||||
target: TProject,
|
||||
resourcesPath: string,
|
||||
domain: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
customLogo: string
|
||||
}
|
||||
|
||||
function getDistribution(array: any[], key: string, current: number) {
|
||||
const count = array.reduce((a, e) => a + e[key], 0);
|
||||
return (100 / count * array[current][key]).toFixed(2);
|
||||
}
|
||||
|
||||
export async function getPage1(options: BaseOptions) {
|
||||
|
||||
const page1_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page1.html'), 'utf-8');
|
||||
const page1 = page1_raw
|
||||
.replace('%PROJECT_NAME%', options.target.name)
|
||||
.replace('%DOMAIN%', options.domain)
|
||||
.replace('%FROM%', options.from.toLocaleString())
|
||||
.replace('%TO%', options.to.toLocaleString())
|
||||
.replace('%LOGO%', options.customLogo)
|
||||
return page1;
|
||||
}
|
||||
|
||||
export async function getPage2(options: BaseOptions) {
|
||||
const page2_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page2.html'), 'utf-8');
|
||||
|
||||
const [visit_count, session_count, pages, countries, devices, referrers, session_duration, bouncing_rate] = await Promise.all([
|
||||
VisitModel.countDocuments({
|
||||
project_id: options.target._id,
|
||||
created_at: {
|
||||
$gte: options.from,
|
||||
$lte: options.to
|
||||
},
|
||||
website: options.domain
|
||||
}),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
uniqueSessions: { $addToSet: "$session" }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
uniqueSessionCount: { $size: "$uniqueSessions" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$page", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$country", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: {
|
||||
$gte: options.from,
|
||||
$lte: options.to
|
||||
},
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$device", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: {
|
||||
$gte: options.from,
|
||||
$lte: options.to
|
||||
},
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
durationController.executeDynamic({
|
||||
project_id: options.target._id.toString(),
|
||||
from: options.from.getTime(), to: options.to.getTime(),
|
||||
slice: 'day', domain: options.domain
|
||||
}),
|
||||
await bouncingController.executeDynamic({
|
||||
project_id: options.target._id.toString(),
|
||||
from: options.from.getTime(), to: options.to.getTime(),
|
||||
slice: 'day', domain: options.domain
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
|
||||
const ai = AiService.init();
|
||||
|
||||
|
||||
const data: any[] = [
|
||||
`- Visits: ${visit_count}`,
|
||||
`- Sessions: ${session_count[0].uniqueSessionCount}`,
|
||||
`- Most viewed pages: ${JSON.stringify(pages)}`,
|
||||
`- Most viewed from countries: ${JSON.stringify(countries)}`,
|
||||
`- Most viewed from devices: ${JSON.stringify(devices)}`,
|
||||
`- Date of analytics: ${options.from.toISOString()} to ${options.to.toISOString()}`,
|
||||
`- Domain of analytics: ${options.domain}`,
|
||||
]
|
||||
|
||||
const aiResponse = await ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 600 characters):\n${data.join('\n')}`
|
||||
})
|
||||
|
||||
|
||||
|
||||
const page2 = page2_raw
|
||||
.replace('%VISITS%', formatNumberK(visit_count ?? 0, 1))
|
||||
.replace('%SESSIONS%', formatNumberK(session_count[0].uniqueSessionCount ?? 0, 1))
|
||||
.replace('%BOUNCING_RATE%', (bouncing_rate.data.reduce((a, e) => a + e.count, 0) / bouncing_rate.data.length).toFixed(2) + '%')
|
||||
.replace('%SESSION_DURATION%', formatTime(session_duration.data.reduce((a, e) => a + e.count, 0) * 1000))
|
||||
|
||||
.replace('%PAGE_0%', pages[0]?._id)
|
||||
.replace('%PAGE_1%', pages[1]?._id)
|
||||
.replace('%PAGE_2%', pages[2]?._id)
|
||||
|
||||
.replace('%COUNTRY_0%', countries[0]?._id)
|
||||
.replace('%COUNTRY_1%', countries[1]?._id)
|
||||
.replace('%COUNTRY_2%', countries[2]?._id)
|
||||
|
||||
.replace('%DEVICE_0%', devices[0]?._id)
|
||||
.replace('%DEVICE_1%', devices[1]?._id)
|
||||
.replace('%DEVICE_2%', devices[2]?._id)
|
||||
|
||||
.replace('%REFERRER_0%', referrers[0]?._id)
|
||||
.replace('%REFERRER_1%', referrers[1]?._id)
|
||||
.replace('%REFERRER_2%', referrers[2]?._id)
|
||||
|
||||
.replace('%AI_0%', aiResponse.output_text)
|
||||
|
||||
.replace('%LOGO%', options.customLogo)
|
||||
|
||||
return page2;
|
||||
}
|
||||
|
||||
|
||||
export async function getPage3(options: BaseOptions) {
|
||||
|
||||
const page3_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page3.html'), 'utf-8');
|
||||
|
||||
const [visits, sessions, devices, visits_timeline] = await Promise.all([
|
||||
VisitModel.countDocuments({
|
||||
project_id: options.target._id,
|
||||
created_at: {
|
||||
$gte: options.from,
|
||||
$lte: options.to
|
||||
},
|
||||
website: options.domain
|
||||
}),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: {
|
||||
$gte: options.from,
|
||||
$lte: options.to
|
||||
},
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$session" } },
|
||||
{ $count: "count" }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: {
|
||||
$gte: options.from,
|
||||
$lte: options.to
|
||||
},
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$device", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
visitController.executeDynamic({
|
||||
project_id: options.target._id.toString(),
|
||||
from: options.from.getTime(), to: options.to.getTime(),
|
||||
slice: 'day', domain: options.domain
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
const ai = AiService.init();
|
||||
|
||||
const [ai0, ai1] = await Promise.all([
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- Visits: ${JSON.stringify(visits)}\n- Sessions: ${JSON.stringify(sessions)}`
|
||||
}),
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 500 characters):\n- Visits: ${JSON.stringify(visits_timeline)}`
|
||||
})
|
||||
])
|
||||
|
||||
const page3 = page3_raw
|
||||
.replace('%VISITS%', visits.toString())
|
||||
.replace('%SESSIONS%', sessions[0].count)
|
||||
.replace('%DEVICE_0%', `${devices[0]._id} - ${devices[0].count}`)
|
||||
.replace('%DEVICE_1%', `${devices[1]._id} - ${devices[1].count}`)
|
||||
.replace('%DEVICE_2%', `${devices[2]._id} - ${devices[2].count}`)
|
||||
|
||||
.replace('VAR_LABELS_TRAFFIC', `[${visits_timeline.data.map(e => `"${e.count}"`).join(',')}]`)
|
||||
.replace('VAR_DATA_TRAFFIC', `[${visits_timeline.data.map(e => e.count).join(',')}]`)
|
||||
|
||||
.replace('VAR_LABELS_PIE', `[${devices.map(e => `"${e._id}"`).join(',')}]`)
|
||||
.replace('VAR_DATA_PIE', `[${devices.map(e => e.count).join(',')}]`)
|
||||
|
||||
.replace('%AI_0%', ai0.output_text)
|
||||
.replace('%AI_1%', ai1.output_text)
|
||||
|
||||
.replace('%LOGO%', options.customLogo)
|
||||
|
||||
return page3;
|
||||
}
|
||||
|
||||
export async function getPage4(options: BaseOptions) {
|
||||
|
||||
const page4_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page4.html'), 'utf-8');
|
||||
|
||||
const [referrers, utm_sources, utm_campaign, utm_medium] = await Promise.all([
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain,
|
||||
utm_source: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$utm_source',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain,
|
||||
utm_campaign: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$utm_campaign',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain,
|
||||
utm_medium: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$utm_medium',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
])
|
||||
]);
|
||||
|
||||
const ai = AiService.init();
|
||||
|
||||
const [ai0, ai1, ai2, ai3] = await Promise.all([
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- Referrers: ${JSON.stringify(referrers)}`
|
||||
}),
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- utm_sources: ${JSON.stringify(utm_sources)}`
|
||||
}),
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- utm_campaign: ${JSON.stringify(utm_campaign)}`
|
||||
}),
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- utm_medium: ${JSON.stringify(utm_medium)}`
|
||||
})
|
||||
])
|
||||
|
||||
|
||||
const page4 = page4_raw
|
||||
.replace('%REFERRER_0%', `${referrers[0]._id} - ${referrers[0].count}`)
|
||||
.replace('%REFERRER_1%', `${referrers[1]._id} - ${referrers[1].count}`)
|
||||
.replace('%REFERRER_2%', `${referrers[2]._id} - ${referrers[2].count}`)
|
||||
.replace('%REFERRER_3%', `${referrers[3]._id} - ${referrers[3].count}`)
|
||||
.replace('%REFERRER_4%', `${referrers[4]._id} - ${referrers[4].count}`)
|
||||
|
||||
.replace('%UTMSOURCE_0%', `${utm_sources[0]?._id} -> ${utm_sources[0]?.count}`)
|
||||
.replace('%UTMSOURCE_1%', `${utm_sources[1]?._id} -> ${utm_sources[1]?.count}`)
|
||||
.replace('%UTMSOURCE_2%', `${utm_sources[2]?._id} -> ${utm_sources[2]?.count}`)
|
||||
|
||||
.replace('%UTMCAMPAIGN_0%', `${utm_campaign[0]?._id} -> ${utm_campaign[0]?.count}`)
|
||||
.replace('%UTMCAMPAIGN_1%', `${utm_campaign[1]?._id} -> ${utm_campaign[1]?.count}`)
|
||||
.replace('%UTMCAMPAIGN_2%', `${utm_campaign[2]?._id} -> ${utm_campaign[2]?.count}`)
|
||||
|
||||
.replace('%UTMMEDIUM_0%', `${utm_medium[0]?._id} -> ${utm_medium[0]?.count}`)
|
||||
.replace('%UTMMEDIUM_1%', `${utm_medium[1]?._id} -> ${utm_medium[1]?.count}`)
|
||||
.replace('%UTMMEDIUM_2%', `${utm_medium[2]?._id} -> ${utm_medium[2]?.count}`)
|
||||
|
||||
.replace('%AI_0%', ai0.output_text)
|
||||
.replace('%AI_1%', ai1.output_text)
|
||||
.replace('%AI_2%', ai2.output_text)
|
||||
.replace('%AI_3%', ai3.output_text)
|
||||
|
||||
|
||||
.replace('%LOGO%', options.customLogo)
|
||||
|
||||
return page4;
|
||||
}
|
||||
|
||||
export async function getPage5(options: BaseOptions) {
|
||||
|
||||
const page5_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page5.html'), 'utf-8');
|
||||
|
||||
const [referrers, utm_term, utm_content] = await Promise.all([
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain,
|
||||
utm_term: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$utm_term',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain,
|
||||
utm_content: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$utm_content',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 3 }
|
||||
]),
|
||||
]);
|
||||
|
||||
const ai = AiService.init();
|
||||
|
||||
const [ai0, ai1] = await Promise.all([
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- utm_term: ${JSON.stringify(utm_term)}`
|
||||
}),
|
||||
ai.responses.create({
|
||||
model: 'gpt-5-nano',
|
||||
input: `Generate an insight of the current website analytics data (max 220 characters):\n- utm_content: ${JSON.stringify(utm_content)}`
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
let page5 = page5_raw
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
page5 = page5.replace(`%REFERRER_${i}_NAME%`, `${referrers[i]?._id}`)
|
||||
.replace(`%REFERRER_${i}_COUNT%`, `${referrers[i]?.count}`)
|
||||
.replace(`%REFERRER_${i}_PERCENT%`, `${getDistribution(referrers, 'count', i)} %`)
|
||||
}
|
||||
|
||||
page5 = page5.replace(`%UTMTERM_0%`, `${utm_term[0]?._id} → ${utm_term[0]?.count}`)
|
||||
page5 = page5.replace(`%UTMTERM_1%`, `${utm_term[1]?._id} → ${utm_term[1]?.count}`)
|
||||
page5 = page5.replace(`%UTMTERM_2%`, `${utm_term[2]?._id} → ${utm_term[2]?.count}`)
|
||||
page5 = page5.replace(`%UTMCONTENT_0%`, `${utm_content[0]?._id} → ${utm_content[0]?.count}`)
|
||||
page5 = page5.replace(`%UTMCONTENT_1%`, `${utm_content[1]?._id} → ${utm_content[1]?.count}`)
|
||||
page5 = page5.replace(`%UTMCONTENT_2%`, `${utm_content[2]?._id} → ${utm_content[2]?.count}`)
|
||||
|
||||
page5 = page5.replace(`%AI_0%`, ai0.output_text)
|
||||
page5 = page5.replace(`%AI_1%`, ai1.output_text)
|
||||
|
||||
return page5;
|
||||
}
|
||||
|
||||
export async function getPage6(options: BaseOptions) {
|
||||
const page6_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page6.html'), 'utf-8');
|
||||
|
||||
const [pages, entry_pages, exit_pages] = await Promise.all([
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$page", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $sort: { session: 1, created_at: 1 } },
|
||||
{ $group: { _id: "$session", entryPage: { $first: "$page" } } },
|
||||
{ $group: { _id: "$entryPage", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $sort: { session: 1, created_at: 1 } },
|
||||
{ $group: { _id: "$session", exitPage: { $last: "$page" } } },
|
||||
{ $group: { _id: "$exitPage", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
])
|
||||
])
|
||||
|
||||
const page6 = page6_raw
|
||||
.replace('%PAGE_0%', `${pages[0]?._id} → ${pages[0]?.count}`)
|
||||
.replace('%PAGE_1%', `${pages[1]?._id} → ${pages[1]?.count}`)
|
||||
.replace('%PAGE_2%', `${pages[2]?._id} → ${pages[2]?.count}`)
|
||||
.replace('%PAGE_3%', `${pages[3]?._id} → ${pages[3]?.count}`)
|
||||
.replace('%PAGE_4%', `${pages[4]?._id} → ${pages[4]?.count}`)
|
||||
|
||||
.replace('%ENTRY_PAGE_0%', `${entry_pages[0]?._id} → ${entry_pages[0]?.count}`)
|
||||
.replace('%ENTRY_PAGE_1%', `${entry_pages[1]?._id} → ${entry_pages[1]?.count}`)
|
||||
.replace('%ENTRY_PAGE_2%', `${entry_pages[2]?._id} → ${entry_pages[2]?.count}`)
|
||||
.replace('%ENTRY_PAGE_3%', `${entry_pages[3]?._id} → ${entry_pages[3]?.count}`)
|
||||
.replace('%ENTRY_PAGE_4%', `${entry_pages[4]?._id} → ${entry_pages[4]?.count}`)
|
||||
|
||||
.replace('%EXIT_PAGE_0%', `${exit_pages[0]?._id} → ${exit_pages[0]?.count}`)
|
||||
.replace('%EXIT_PAGE_1%', `${exit_pages[1]?._id} → ${exit_pages[1]?.count}`)
|
||||
.replace('%EXIT_PAGE_2%', `${exit_pages[2]?._id} → ${exit_pages[2]?.count}`)
|
||||
.replace('%EXIT_PAGE_3%', `${exit_pages[3]?._id} → ${exit_pages[3]?.count}`)
|
||||
.replace('%EXIT_PAGE_4%', `${exit_pages[4]?._id} → ${exit_pages[4]?.count}`)
|
||||
|
||||
return page6;
|
||||
}
|
||||
|
||||
export async function getPage7(options: BaseOptions) {
|
||||
const page7_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page7.html'), 'utf-8');
|
||||
|
||||
|
||||
const [pages, sessions] = await Promise.all([
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{
|
||||
$setWindowFields: {
|
||||
partitionBy: "$session",
|
||||
sortBy: { created_at: 1 },
|
||||
output: {
|
||||
nextCreatedAt: { $shift: { output: "$created_at", by: 1 } },
|
||||
nextPage: { $shift: { output: "$page", by: 1 } }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
page: 1,
|
||||
created_at: 1,
|
||||
nextCreatedAt: 1,
|
||||
durationMs: {
|
||||
$cond: [
|
||||
{ $ne: ["$nextCreatedAt", null] },
|
||||
{ $subtract: ["$nextCreatedAt", "$created_at"] },
|
||||
null
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
durationMs: { $ne: null, $gt: 0, $lte: 1000 * 60 * 60 * 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$page",
|
||||
count: { $sum: 1 },
|
||||
avgMs: { $avg: "$durationMs" }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
count: 1,
|
||||
avgSeconds: { $round: [{ $divide: ["$avgMs", 1000] }, 0] },
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 8 },
|
||||
]),
|
||||
durationController.executeDynamic({
|
||||
project_id: options.target._id.toString(),
|
||||
from: options.from.getTime(), to: options.to.getTime(),
|
||||
slice: 'day', domain: options.domain
|
||||
})
|
||||
])
|
||||
|
||||
let page7 = page7_raw
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
page7 = page7
|
||||
.replace(`%ITEM_${i}_A%`, `${pages[i]?._id}`)
|
||||
.replace(`%ITEM_${i}_B%`, `${pages[i]?.count}`)
|
||||
.replace(`%ITEM_${i}_C%`, `${formatTime(pages[i]?.avgSeconds * 1000)}`)
|
||||
.replace(`%ITEM_${i}_D%`, `${getDistribution(pages, 'count', i)} %`)
|
||||
|
||||
.replace(`%AVG_PAGE_TIME%`, `${formatTime((pages.reduce((a, e) => a + e.avgSeconds, 0) / pages.length) * 1000)}`)
|
||||
.replace(`%AVG_SESSION_TIME%`, `${formatTime((sessions.data.reduce((a, e) => a + e.count, 0) / sessions.data.length) * 1000)}`)
|
||||
|
||||
.replace('%LOGO%', options.customLogo)
|
||||
}
|
||||
|
||||
return page7;
|
||||
}
|
||||
|
||||
export async function getPage8(options: BaseOptions) {
|
||||
const page8_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page8.html'), 'utf-8');
|
||||
|
||||
|
||||
const [continents, countries] = await Promise.all([
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$continent", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 4 }
|
||||
]),
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$country", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
])
|
||||
])
|
||||
|
||||
let page8 = page8_raw
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
page8 = page8
|
||||
.replace(`%CONTINENT_${i}_A%`, `${continents[i]?._id}`)
|
||||
.replace(`%CONTINENT_${i}_B%`, `${continents[i]?.count}`)
|
||||
.replace(`%CONTINENT_${i}_C%`, `${getDistribution(continents, 'count', i)}%`)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
page8 = page8
|
||||
.replace(`%COUNTRY_${i}_A%`, `${countries[i]?._id}`)
|
||||
.replace(`%COUNTRY_${i}_B%`, `${countries[i]?.count}`)
|
||||
.replace(`%COUNTRY_${i}_C%`, `${getDistribution(countries, 'count', i)}%`)
|
||||
}
|
||||
|
||||
page8 = page8.replace('%LOGO%', options.customLogo)
|
||||
|
||||
return page8;
|
||||
}
|
||||
|
||||
export async function getPage9(options: BaseOptions) {
|
||||
const page9_raw = fs.readFileSync(path.join(options.resourcesPath, 'pages/page9.html'), 'utf-8');
|
||||
|
||||
|
||||
const [cities] = await Promise.all([
|
||||
VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: options.target._id,
|
||||
created_at: { $gte: options.from, $lte: options.to },
|
||||
website: options.domain
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$city", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]),
|
||||
])
|
||||
|
||||
let page9 = page9_raw
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
page9 = page9
|
||||
.replace(`%CITY_${i}_A%`, `${cities[i]?._id ?? 'Unknown'}`)
|
||||
.replace(`%CITY_${i}_B%`, `${cities[i]?.count}`)
|
||||
.replace(`%CITY_${i}_C%`, `${getDistribution(cities, 'count', i)}%`)
|
||||
}
|
||||
|
||||
page9 = page9.replace('%LOGO%', options.customLogo)
|
||||
|
||||
return page9;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getResourcePath() {
|
||||
if (isSelfhosted()) return '/home/app/public/pdf/';
|
||||
return process.dev ? './public/pdf/' : './.output/public/pdf/';
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const ctx = await getRequestContext(event, 'pid', 'range');
|
||||
|
||||
const project = await ProjectModel.findById(ctx.project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
const { customLogo } = await readBody(event);
|
||||
|
||||
const target = project;
|
||||
const from = new Date(ctx.from);
|
||||
const to = new Date(ctx.to);
|
||||
const query = getQuery(event);
|
||||
const domain = query.domain as string;
|
||||
|
||||
const resourcesPath = getResourcePath();
|
||||
|
||||
|
||||
const [page1, page2, page3, page4, page5, page6, page7, page8, page9] = await Promise.all([
|
||||
getPage1({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage2({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage3({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage4({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage5({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage6({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage7({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage8({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
getPage9({ resourcesPath, domain, from, to, target, customLogo: customLogo ?? 'https://dashboard.litlyx.com/pdf/pdf_images/logo-black.png' }),
|
||||
])
|
||||
|
||||
|
||||
const CUSTOM_CHROMIUM = fs.existsSync('/usr/bin/chromium-browser');
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CUSTOM_CHROMIUM ? '/usr/bin/chromium-browser' : undefined,
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-features=site-per-process',
|
||||
'--aggressive-cache-discard',
|
||||
'--disable-cache',
|
||||
'--disable-application-cache',
|
||||
'--disable-offline-load-stale-cache',
|
||||
'--disable-gpu-shader-disk-cache',
|
||||
'--media-cache-size=0',
|
||||
'--disk-cache-size=0',
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.setContent(page1, { waitUntil: "load" });
|
||||
const page1buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page2, { waitUntil: "load" });
|
||||
const page2buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page3, { waitUntil: "load" });
|
||||
await new Promise(e => setTimeout(e, 1000));
|
||||
const page3buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page4, { waitUntil: "load" });
|
||||
const page4buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page5, { waitUntil: "load" });
|
||||
const page5buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page6, { waitUntil: "load" });
|
||||
const page6buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page7, { waitUntil: "load" });
|
||||
const page7buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page8, { waitUntil: "load" });
|
||||
const page8buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await page.setContent(page9, { waitUntil: "load" });
|
||||
const page9buffer = await page.pdf({ format: "A4", printBackground: true });
|
||||
|
||||
await browser.close();
|
||||
|
||||
|
||||
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
const pdf1 = await PDFDocument.load(page1buffer);
|
||||
const copiedPages1 = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices());
|
||||
copiedPages1.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf2 = await PDFDocument.load(page2buffer);
|
||||
const copiedPages2 = await mergedPdf.copyPages(pdf2, pdf2.getPageIndices());
|
||||
copiedPages2.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf3 = await PDFDocument.load(page3buffer);
|
||||
const copiedPages3 = await mergedPdf.copyPages(pdf3, pdf3.getPageIndices());
|
||||
copiedPages3.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf4 = await PDFDocument.load(page4buffer);
|
||||
const copiedPages4 = await mergedPdf.copyPages(pdf4, pdf4.getPageIndices());
|
||||
copiedPages4.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf5 = await PDFDocument.load(page5buffer);
|
||||
const copiedPages5 = await mergedPdf.copyPages(pdf5, pdf5.getPageIndices());
|
||||
copiedPages5.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf6 = await PDFDocument.load(page6buffer);
|
||||
const copiedPages6 = await mergedPdf.copyPages(pdf6, pdf6.getPageIndices());
|
||||
copiedPages6.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf7 = await PDFDocument.load(page7buffer);
|
||||
const copiedPages7 = await mergedPdf.copyPages(pdf7, pdf7.getPageIndices());
|
||||
copiedPages7.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf8 = await PDFDocument.load(page8buffer);
|
||||
const copiedPages8 = await mergedPdf.copyPages(pdf8, pdf8.getPageIndices());
|
||||
copiedPages8.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const pdf9 = await PDFDocument.load(page9buffer);
|
||||
const copiedPages9 = await mergedPdf.copyPages(pdf9, pdf9.getPageIndices());
|
||||
copiedPages9.forEach((p) => mergedPdf.addPage(p));
|
||||
|
||||
const finalPdfBytes = await mergedPdf.save();
|
||||
|
||||
setResponseHeaders(event, {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'attachment; filename="AdvancedReport.pdf"',
|
||||
});
|
||||
|
||||
return finalPdfBytes;
|
||||
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
|
||||
|
||||
const data = await getRequestDataOld(event);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return;
|
||||
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
|
||||
return {
|
||||
total: TOTAL_COUNT,
|
||||
limit: COUNT_LIMIT,
|
||||
maxLimit: Math.round(COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT),
|
||||
limited: TOTAL_COUNT > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT,
|
||||
percent: Math.round(100 / COUNT_LIMIT * TOTAL_COUNT)
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { ProjectLinkModel } from "~/shared/schema/project/ProjectLinkSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], ['OWNER']);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id, project } = data;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return setResponseStatus(event, 400, 'No owner');
|
||||
|
||||
const links = await ProjectLinkModel.find({ project_id });
|
||||
return links;
|
||||
|
||||
});
|
||||
@@ -2,11 +2,10 @@ import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return [];
|
||||
|
||||
|
||||
const userProjects = await ProjectModel.find({ owner: userData.id });
|
||||
return userProjects.map(e => e.toJSON()) as TProject[];
|
||||
const ctx = await getRequestContext(event);
|
||||
const { user_id } = ctx;
|
||||
|
||||
const projects = await ProjectModel.find({ owner: user_id });
|
||||
return projects.map(e => e.toJSON()) as TProject[];
|
||||
|
||||
});
|
||||
@@ -1,23 +1,17 @@
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
import { TTeamMember, TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return [];
|
||||
const ctx = await getRequestContext(event);
|
||||
const { user_id, user_email } = ctx;
|
||||
|
||||
const members = await TeamMemberModel.find({
|
||||
$or: [{ user_id }, { email: user_email }],
|
||||
pending: false
|
||||
});
|
||||
|
||||
const members = await TeamMemberModel.find({ user_id: userData.id, pending: false });
|
||||
|
||||
const projects: TProject[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
const project = await ProjectModel.findById(member.project_id);
|
||||
if (!project) continue;
|
||||
projects.push(project.toJSON());
|
||||
}
|
||||
|
||||
return projects;
|
||||
|
||||
const projects = await ProjectModel.find({ _id: { $in: members.map(e => e.project_id) } });
|
||||
return projects.map(e => e.toJSON()) as TProject[];
|
||||
|
||||
});
|
||||
31
dashboard/server/api/project/live_users.ts
Normal file
31
dashboard/server/api/project/live_users.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const ctx = await getRequestContext(event, 'pid', 'permission:member');
|
||||
const { project_id } = ctx;
|
||||
|
||||
const timespan = dayjs(Date.now() - 1000 * 60 * 5).utc().toDate()
|
||||
|
||||
const live_users = await VisitModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id,
|
||||
created_at: { $gte: timespan }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$session',
|
||||
session: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$count: 'sessions'
|
||||
}
|
||||
]);
|
||||
|
||||
return live_users[0]?.sessions || 0 as number;
|
||||
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], []);
|
||||
if (!data) return [];
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const { project_id } = body;
|
||||
if (!project_id) return setResponseStatus(event, 400, 'project_id is required');
|
||||
|
||||
console.log({ project_id, user_id: data.user.id });
|
||||
|
||||
const member = await TeamMemberModel.findOne({ project_id, user_id: data.user.id });
|
||||
if (!member) return setResponseStatus(event, 400, 'member not found');
|
||||
|
||||
member.pending = false;
|
||||
await member.save();
|
||||
|
||||
return { ok: true };
|
||||
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { EmailServiceHelper } from "~/server/services/EmailServiceHelper";
|
||||
import { EmailService } from "~/shared/services/EmailService";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], ['OWNER']);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id, project, user } = data;
|
||||
|
||||
const { email } = await readBody(event);
|
||||
|
||||
const targetUser = await UserModel.findOne({ email });
|
||||
|
||||
if (targetUser && targetUser._id.toString() === project.owner.toString()) {
|
||||
return setResponseStatus(event, 400, 'You cannot invite yourself');
|
||||
}
|
||||
|
||||
|
||||
const link = `https://dashboard.litlyx.com/accept_invite?project_id=${project_id.toString()}`;
|
||||
|
||||
if (!targetUser) {
|
||||
|
||||
const exist = await TeamMemberModel.exists({ project_id, email });
|
||||
if (exist) return setResponseStatus(event, 400, 'Member already invited');
|
||||
|
||||
await TeamMemberModel.create({
|
||||
project_id,
|
||||
email,
|
||||
pending: true,
|
||||
role: 'GUEST'
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
const emailData = EmailService.getEmailServerInfo('invite_project_noaccount', {
|
||||
target: email,
|
||||
projectName: project.name,
|
||||
link
|
||||
});
|
||||
EmailServiceHelper.sendEmail(emailData);
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
||||
} else {
|
||||
|
||||
const exist = await TeamMemberModel.exists({ project_id, user_id: targetUser.id });
|
||||
if (exist) return setResponseStatus(event, 400, 'Member already invited');
|
||||
|
||||
await TeamMemberModel.create({
|
||||
project_id,
|
||||
user_id: targetUser.id,
|
||||
pending: true,
|
||||
role: 'GUEST'
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
const emailData = EmailService.getEmailServerInfo('invite_project', {
|
||||
|
||||
target: email,
|
||||
projectName: project.name,
|
||||
link
|
||||
});
|
||||
EmailServiceHelper.sendEmail(emailData);
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], []);
|
||||
if (!data) return [];
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const { project_id } = body;
|
||||
if (!project_id) return setResponseStatus(event, 400, 'project_id is required');
|
||||
|
||||
const member = await TeamMemberModel.deleteOne({ project_id, user_id: data.user.id });
|
||||
if (!member) return setResponseStatus(event, 400, 'member not found');
|
||||
|
||||
return { ok: true };
|
||||
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], ['OWNER']);
|
||||
if (!data) return [];
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const { member_id, webAnalytics, events, ai, domains } = body;
|
||||
|
||||
if (!member_id) return setResponseStatus(event, 400, 'permission_id is required');
|
||||
|
||||
const edited = await TeamMemberModel.updateOne({ _id: member_id }, {
|
||||
permission: {
|
||||
webAnalytics,
|
||||
events,
|
||||
ai,
|
||||
domains
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: edited.modifiedCount == 1 }
|
||||
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
import { TeamMemberModel, TPermission, TTeamMember } from "@schema/TeamMemberSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event, []);
|
||||
if (!data) return;
|
||||
const { member_id } = getQuery(event);
|
||||
const member = await TeamMemberModel.findById(member_id);
|
||||
if (!member) return setResponseStatus(event, 400, 'Cannot get member');
|
||||
|
||||
const resultPermission: TPermission = {
|
||||
ai: false,
|
||||
domains: [],
|
||||
events: false,
|
||||
webAnalytics: false
|
||||
}
|
||||
|
||||
return {
|
||||
permission: resultPermission,
|
||||
...member.toJSON() as any
|
||||
} as TTeamMember
|
||||
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], ['OWNER']);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
const { email } = await readBody(event);
|
||||
|
||||
const user = await UserModel.findOne({ email });
|
||||
if (!user) return setResponseStatus(event, 400, 'Email not found');
|
||||
|
||||
await TeamMemberModel.deleteOne({ project_id, user_id: user.id });
|
||||
|
||||
return { ok: true }
|
||||
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], []);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id, user } = data;
|
||||
|
||||
await TeamMemberModel.deleteOne({ project_id, user_id: user.id });
|
||||
|
||||
return { ok: true }
|
||||
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { TeamMemberModel, TeamMemberRole, TPermission, TTeamMember } from "@schema/TeamMemberSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
export type MemberWithPermissions = {
|
||||
id: string | null,
|
||||
email: string,
|
||||
name: string,
|
||||
role: TeamMemberRole,
|
||||
pending: boolean,
|
||||
me: boolean,
|
||||
permission: TPermission
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, [], ['OWNER']);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id, project, user } = data;
|
||||
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
if (!owner) return setResponseStatus(event, 400, 'No owner');
|
||||
|
||||
const members = await TeamMemberModel.find({ project_id });
|
||||
|
||||
const result: MemberWithPermissions[] = [];
|
||||
|
||||
result.push({
|
||||
id: null,
|
||||
email: owner.email,
|
||||
name: owner.name,
|
||||
role: 'OWNER',
|
||||
pending: false,
|
||||
me: user.id === owner.id,
|
||||
permission: {
|
||||
webAnalytics: true,
|
||||
events: true,
|
||||
ai: true,
|
||||
domains: ['All domains']
|
||||
}
|
||||
})
|
||||
|
||||
for (const member of members) {
|
||||
const userMember = member.user_id ? await UserModel.findById(member.user_id) : await UserModel.findOne({ email: member.email });
|
||||
if (!userMember) continue;
|
||||
|
||||
const permission: TPermission = {
|
||||
webAnalytics: member.permission?.webAnalytics || false,
|
||||
events: member.permission?.events || false,
|
||||
ai: member.permission?.ai || false,
|
||||
domains: member.permission?.domains || []
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: member.id,
|
||||
email: userMember.email,
|
||||
name: userMember.name,
|
||||
role: member.role,
|
||||
pending: member.pending,
|
||||
me: user.id === userMember.id,
|
||||
permission
|
||||
})
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { TeamMemberModel, TeamMemberRole, TPermission, TTeamMember } from "@schema/TeamMemberSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, []);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id, project, user } = data;
|
||||
|
||||
const owner = await UserModel.findById(project.owner, { _id: 1 });
|
||||
|
||||
if (owner && owner._id.toString() === user.id) return {
|
||||
ai: true,
|
||||
domains: ['All domains'],
|
||||
events: true,
|
||||
webAnalytics: true
|
||||
}
|
||||
|
||||
const member = await TeamMemberModel.findOne({ project_id, user_id: user.id });
|
||||
|
||||
if (!member) return {
|
||||
ai: true,
|
||||
domains: ['All domains'],
|
||||
events: true,
|
||||
webAnalytics: true
|
||||
}
|
||||
|
||||
return {
|
||||
ai: false,
|
||||
domains: [],
|
||||
events: false,
|
||||
webAnalytics: false,
|
||||
...(member.permission as any),
|
||||
} as TPermission
|
||||
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const members = await TeamMemberModel.aggregate([
|
||||
{
|
||||
$match:
|
||||
{
|
||||
$or: [
|
||||
{ user_id: new Types.ObjectId(data.user.id) },
|
||||
{ email: data.user.user.email }
|
||||
],
|
||||
pending: true
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'projects',
|
||||
as: 'project',
|
||||
foreignField: '_id',
|
||||
localField: 'project_id',
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
project_name: { $arrayElemAt: ["$project.name", 0] }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
project: 0
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
return members;
|
||||
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, []);
|
||||
if (!data) return;
|
||||
|
||||
const { project, project_id } = data;
|
||||
|
||||
if (project.subscription_id === 'onetime') {
|
||||
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return setResponseStatus(event, 400, 'Project limits not found');
|
||||
|
||||
const result = {
|
||||
premium: project.premium,
|
||||
premium_type: project.premium_type,
|
||||
billing_start_at: projectLimits.billing_start_at,
|
||||
billing_expire_at: projectLimits.billing_expire_at,
|
||||
limit: projectLimits.limit,
|
||||
count: projectLimits.events + projectLimits.visits,
|
||||
subscription_status: StripeService.isDisabled() ? 'Disabled mode' : ('One time payment')
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const subscription = await StripeService.getSubscription(project.subscription_id);
|
||||
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return setResponseStatus(event, 400, 'Project limits not found');
|
||||
|
||||
|
||||
const result = {
|
||||
premium: project.premium,
|
||||
premium_type: project.premium_type,
|
||||
billing_start_at: projectLimits.billing_start_at,
|
||||
billing_expire_at: projectLimits.billing_expire_at,
|
||||
limit: projectLimits.limit,
|
||||
count: projectLimits.events + projectLimits.visits,
|
||||
subscription_status: StripeService.isDisabled() ? 'Disabled mode' : (subscription?.status ?? '?')
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
import { ProjectSnapshotModel, TProjectSnapshot } from "@schema/project/ProjectSnapshot";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestDataOld(event, { requireSchema: false, allowLitlyx: false });
|
||||
if (!data) return;
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
const snapshots = await ProjectSnapshotModel.find({ project_id });
|
||||
|
||||
return snapshots.map(e => e.toJSON()) as TProjectSnapshot[];
|
||||
|
||||
});
|
||||
28
dashboard/server/api/project/stats.ts
Normal file
28
dashboard/server/api/project/stats.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
import { executeAdvancedTimelineAggregation } from "~/server/services/TimelineService";
|
||||
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const ctx = await getRequestContext(event);
|
||||
const { user_id } = ctx;
|
||||
|
||||
const { pid } = getQuery(event);
|
||||
|
||||
const project = await ProjectModel.findOne({ _id: pid, owner: user_id });
|
||||
if (!project) return;
|
||||
|
||||
const timelineData = await executeAdvancedTimelineAggregation({
|
||||
projectId: project._id,
|
||||
model: VisitModel,
|
||||
from: Date.now() - 1000 * 60 * 60 * 24,
|
||||
to: Date.now(),
|
||||
slice: 'hour'
|
||||
});
|
||||
|
||||
const labels = timelineData.map(e => e.timestamp);
|
||||
const data = timelineData.map(e => e.count);
|
||||
|
||||
return { chart: { labels, data } };
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user