new selfhosted version

This commit is contained in:
antonio
2025-11-28 14:11:51 +01:00
parent afda29997d
commit 951860f67e
1046 changed files with 72586 additions and 574750 deletions

View File

@@ -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 };
});

View 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 };
});

View File

@@ -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;
});

View File

@@ -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 };
});

View File

@@ -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;

View File

@@ -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 '';
}
});

View 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);
});

View File

@@ -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);
});

View 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;
});

View File

@@ -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)
}
});

View File

@@ -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;
});

View File

@@ -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[];
});

View File

@@ -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[];
});

View 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;
});

View File

@@ -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 };
});

View File

@@ -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 };
}
});

View File

@@ -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 };
});

View File

@@ -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 }
});

View File

@@ -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
});

View File

@@ -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 }
});

View File

@@ -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 }
});

View File

@@ -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;
});

View File

@@ -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
});

View File

@@ -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;
});

View File

@@ -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;
});

View File

@@ -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[];
});

View 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 } };
});