+
+
+ Total events: {{ eventsData.data.value?.[0].total || '???' }}
+
+
-
+
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
index 18c43f2..23eb2a4 100644
--- a/dashboard/pnpm-lock.yaml
+++ b/dashboard/pnpm-lock.yaml
@@ -33,8 +33,11 @@ importers:
specifier: ^1.11.11
version: 1.11.11
google-auth-library:
- specifier: ^9.9.0
+ specifier: ^9.10.0
version: 9.10.0(encoding@0.1.13)
+ googleapis:
+ specifier: ^144.0.0
+ version: 144.0.0(encoding@0.1.13)
highlight.js:
specifier: ^11.10.0
version: 11.10.0
@@ -1499,20 +1502,20 @@ packages:
'@vue/reactivity@3.4.27':
resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==}
- '@vue/reactivity@3.5.8':
- resolution: {integrity: sha512-mlgUyFHLCUZcAYkqvzYnlBRCh0t5ZQfLYit7nukn1GR96gc48Bp4B7OIcSfVSvlG1k3BPfD+p22gi1t2n9tsXg==}
+ '@vue/reactivity@3.5.10':
+ resolution: {integrity: sha512-kW08v06F6xPSHhid9DJ9YjOGmwNDOsJJQk0ax21wKaUYzzuJGEuoKNU2Ujux8FLMrP7CFJJKsHhXN9l2WOVi2g==}
'@vue/runtime-core@3.4.27':
resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==}
- '@vue/runtime-core@3.5.8':
- resolution: {integrity: sha512-fJuPelh64agZ8vKkZgp5iCkPaEqFJsYzxLk9vSC0X3G8ppknclNDr61gDc45yBGTaN5Xqc1qZWU3/NoaBMHcjQ==}
+ '@vue/runtime-core@3.5.10':
+ resolution: {integrity: sha512-9Q86I5Qq3swSkFfzrZ+iqEy7Vla325M7S7xc1NwKnRm/qoi1Dauz0rT6mTMmscqx4qz0EDJ1wjB+A36k7rl8mA==}
'@vue/runtime-dom@3.4.27':
resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==}
- '@vue/runtime-dom@3.5.8':
- resolution: {integrity: sha512-DpAUz+PKjTZPUOB6zJgkxVI3GuYc2iWZiNeeHQUw53kdrparSTG6HeXUrYDjaam8dVsCdvQxDz6ZWxnyjccUjQ==}
+ '@vue/runtime-dom@3.5.10':
+ resolution: {integrity: sha512-t3x7ht5qF8ZRi1H4fZqFzyY2j+GTMTDxRheT+i8M9Ph0oepUxoadmbwlFwMoW7RYCpNQLpP2Yx3feKs+fyBdpA==}
'@vue/server-renderer@3.4.27':
resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==}
@@ -1522,6 +1525,9 @@ packages:
'@vue/shared@3.4.27':
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
+ '@vue/shared@3.5.10':
+ resolution: {integrity: sha512-VkkBhU97Ki+XJ0xvl4C9YJsIZ2uIlQ7HqPpZOS3m9VCvmROPaChZU6DexdMJqvz9tbgG+4EtFVrSuailUq5KGQ==}
+
'@vue/shared@3.5.8':
resolution: {integrity: sha512-mJleSWbAGySd2RJdX1RBtcrUBX6snyOc0qHpgk3lGi4l9/P/3ny3ELqFWqYdkXIwwNN/kdm8nD9ky8o6l/Lx2A==}
@@ -2734,6 +2740,14 @@ packages:
resolution: {integrity: sha512-ol+oSa5NbcGdDqA+gZ3G3mev59OHBZksBTxY/tYwjtcp1H/scAFwJfSQU9/1RALoyZ7FslNbke8j4i3ipwlyuQ==}
engines: {node: '>=14'}
+ googleapis-common@7.2.0:
+ resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==}
+ engines: {node: '>=14.0.0'}
+
+ googleapis@144.0.0:
+ resolution: {integrity: sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==}
+ engines: {node: '>=14.0.0'}
+
gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
@@ -4984,6 +4998,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ url-template@2.0.8:
+ resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
+
urlpattern-polyfill@8.0.2:
resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==}
@@ -7241,19 +7258,19 @@ snapshots:
dependencies:
'@vue/shared': 3.4.27
- '@vue/reactivity@3.5.8':
+ '@vue/reactivity@3.5.10':
dependencies:
- '@vue/shared': 3.5.8
+ '@vue/shared': 3.5.10
'@vue/runtime-core@3.4.27':
dependencies:
'@vue/reactivity': 3.4.27
'@vue/shared': 3.4.27
- '@vue/runtime-core@3.5.8':
+ '@vue/runtime-core@3.5.10':
dependencies:
- '@vue/reactivity': 3.5.8
- '@vue/shared': 3.5.8
+ '@vue/reactivity': 3.5.10
+ '@vue/shared': 3.5.10
'@vue/runtime-dom@3.4.27':
dependencies:
@@ -7261,11 +7278,11 @@ snapshots:
'@vue/shared': 3.4.27
csstype: 3.1.3
- '@vue/runtime-dom@3.5.8':
+ '@vue/runtime-dom@3.5.10':
dependencies:
- '@vue/reactivity': 3.5.8
- '@vue/runtime-core': 3.5.8
- '@vue/shared': 3.5.8
+ '@vue/reactivity': 3.5.10
+ '@vue/runtime-core': 3.5.10
+ '@vue/shared': 3.5.10
csstype: 3.1.3
'@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))':
@@ -7276,6 +7293,8 @@ snapshots:
'@vue/shared@3.4.27': {}
+ '@vue/shared@3.5.10': {}
+
'@vue/shared@3.5.8': {}
'@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))':
@@ -8571,6 +8590,26 @@ snapshots:
- encoding
- supports-color
+ googleapis-common@7.2.0(encoding@0.1.13):
+ dependencies:
+ extend: 3.0.2
+ gaxios: 6.6.0(encoding@0.1.13)
+ google-auth-library: 9.10.0(encoding@0.1.13)
+ qs: 6.12.1
+ url-template: 2.0.8
+ uuid: 9.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
+ googleapis@144.0.0(encoding@0.1.13):
+ dependencies:
+ google-auth-library: 9.10.0(encoding@0.1.13)
+ googleapis-common: 7.2.0(encoding@0.1.13)
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
gopd@1.0.1:
dependencies:
get-intrinsic: 1.2.4
@@ -11143,6 +11182,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ url-template@2.0.8: {}
+
urlpattern-polyfill@8.0.2: {}
util-deprecate@1.0.2: {}
@@ -11347,8 +11388,8 @@ snapshots:
vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)):
dependencies:
- '@vue/runtime-core': 3.5.8
- '@vue/runtime-dom': 3.5.8
+ '@vue/runtime-core': 3.5.10
+ '@vue/runtime-dom': 3.5.10
chart.js: 3.9.1
csstype: 3.1.3
lodash-es: 4.17.21
diff --git a/dashboard/server/api/auth/google_login.post.ts b/dashboard/server/api/auth/google_login.post.ts
index 048fb27..1b1e441 100644
--- a/dashboard/server/api/auth/google_login.post.ts
+++ b/dashboard/server/api/auth/google_login.post.ts
@@ -36,7 +36,14 @@ export default defineEventHandler(async event => {
const user = await UserModel.findOne({ email: payload.email });
- if (user) return { error: false, access_token: createUserJwt({ email: user.email, name: user.name }) }
+ if (user) {
+ user.google_tokens = tokens as any;
+ await user.save();
+ return {
+ error: false,
+ access_token: createUserJwt({ email: user.email, name: user.name })
+ }
+ }
const newUser = new UserModel({
@@ -45,6 +52,7 @@ export default defineEventHandler(async event => {
name: payload.name,
locale: payload.locale,
picture: payload.picture,
+ google_tokens: tokens,
created_at: Date.now()
});
diff --git a/dashboard/server/api/data/bouncing_rate.ts b/dashboard/server/api/data/bouncing_rate.ts
new file mode 100644
index 0000000..f11cedf
--- /dev/null
+++ b/dashboard/server/api/data/bouncing_rate.ts
@@ -0,0 +1,79 @@
+import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
+import { SessionModel } from "@schema/metrics/SessionSchema";
+import { VisitModel } from "@schema/metrics/VisitSchema";
+import { Redis } from "~/server/services/CacheService";
+import DateService from "@services/DateService";
+import mongoose from "mongoose";
+
+export default defineEventHandler(async event => {
+
+ const project_id = getHeader(event, 'x-pid');
+ if (!project_id) return;
+
+ const user = getRequestUser(event);
+ const project = await getUserProjectFromId(project_id, user);
+ if (!project) return;
+
+ const from = getRequestHeader(event, 'x-from');
+ const to = getRequestHeader(event, 'x-to');
+
+ if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to are required');
+
+ const slice = getRequestHeader(event, 'x-slice');
+
+ const cacheKey = `bouncing_rate:${project_id}:${from}:${to}`;
+ const cacheExp = 60 * 60; //1 hour
+
+ return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
+
+ const dateDistDays = (new Date(to).getTime() - new Date(from).getTime()) / (1000 * 60 * 60 * 24)
+ // 15 Days
+ if (slice === 'hour' && (dateDistDays > 15)) throw Error('Date gap too big for this slice');
+ // 1 Year
+ if (slice === 'day' && (dateDistDays > 365)) throw Error('Date gap too big for this slice');
+ // 3 Years
+ if (slice === 'month' && (dateDistDays > 365 * 3)) throw Error('Date gap too big for this slice');
+
+
+ const allDates = DateService.createBetweenDates(from, to, slice as any);
+
+ const result: { date: string, value: number }[] = [];
+
+ for (const date of allDates.dates) {
+
+ const visits = await VisitModel.aggregate([
+ {
+ $match: {
+ project_id: project._id,
+ created_at: {
+ $gte: date.startOf(slice as any).toDate(),
+ $lte: date.endOf(slice as any).toDate()
+ }
+ },
+ },
+ { $group: { _id: "$session", count: { $sum: 1, } } },
+ ]);
+
+ const sessions = await SessionModel.aggregate([
+ {
+ $match: {
+ project_id: project._id,
+ created_at: {
+ $gte: date.startOf(slice as any).toDate(),
+ $lte: date.endOf(slice as any).toDate()
+ }
+ },
+ },
+ { $group: { _id: "$session", count: { $sum: 1, }, duration: { $sum: '$duration' } } },
+ ]);
+
+ const total = visits.length;
+ const bounced = sessions.filter(e => (e.duration / e.count) < 1).length;
+ const bouncing_rate = 100 / total * bounced;
+ result.push({ date: date.toISOString(), value: bouncing_rate });
+ }
+
+ return result;
+
+ });
+});
\ No newline at end of file
diff --git a/dashboard/server/api/data/count.ts b/dashboard/server/api/data/count.ts
new file mode 100644
index 0000000..dc96efe
--- /dev/null
+++ b/dashboard/server/api/data/count.ts
@@ -0,0 +1,61 @@
+import { EventModel } from "@schema/metrics/EventSchema";
+import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
+import { Redis } from "~/server/services/CacheService";
+
+import type { Model } from "mongoose";
+
+
+const allowedModels: Record
, field: string }> = {
+ 'events': {
+ model: EventModel,
+ field: 'name'
+ }
+}
+
+type TModelName = keyof typeof allowedModels;
+
+export default defineEventHandler(async event => {
+ const project_id = getHeader(event, 'x-pid');
+ if (!project_id) return;
+
+ const user = getRequestUser(event);
+ const project = await getUserProjectFromId(project_id, user);
+ if (!project) return;
+
+ const from = getRequestHeader(event, 'x-from');
+ const to = getRequestHeader(event, 'x-to');
+
+ if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to are required');
+
+ const schemaName = getRequestHeader(event, 'x-schema');
+ if (!schemaName) return setResponseStatus(event, 400, 'x-schema is required');
+
+ if (!Object.keys(allowedModels).includes(schemaName)) return setResponseStatus(event, 400, 'x-schema value is not valid');
+
+ const cacheKey = `count:${schemaName}:${project_id}:${from}:${to}`;
+ const cacheExp = 60;
+
+ return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
+
+ const { model } = allowedModels[schemaName as TModelName];
+
+ const result = await model.aggregate([
+ {
+ $match: {
+ project_id: project._id,
+ created_at: {
+ $gte: new Date(from),
+ $lte: new Date(to)
+ }
+ }
+ },
+ {
+ $count: 'total'
+ }
+ ]);
+
+ return result;
+
+ });
+
+});
\ No newline at end of file
diff --git a/dashboard/server/api/project/generate_csv.ts b/dashboard/server/api/project/generate_csv.ts
index e50f4d0..3ec6f30 100644
--- a/dashboard/server/api/project/generate_csv.ts
+++ b/dashboard/server/api/project/generate_csv.ts
@@ -1,9 +1,62 @@
import { ProjectModel } from "@schema/ProjectSchema";
+import { UserModel } from "@schema/UserSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { EventModel } from '@schema/metrics/EventSchema';
import { VisitModel } from "@schema/metrics/VisitSchema";
+import { google } from 'googleapis';
+
+const { GOOGLE_AUTH_CLIENT_SECRET, GOOGLE_AUTH_CLIENT_ID } = useRuntimeConfig()
+
+async function exportToGoogle(data: string, user_id: string) {
+
+ const user = await UserModel.findOne({ _id: user_id }, { google_tokens: true });
+
+ const authClient = new google.auth.OAuth2({
+ clientId: GOOGLE_AUTH_CLIENT_ID,
+ clientSecret: GOOGLE_AUTH_CLIENT_SECRET
+ })
+
+ authClient.setCredentials({ access_token: user?.google_tokens?.access_token, refresh_token: user?.google_tokens?.refresh_token });
+
+ const sheets = google.sheets({ version: 'v4', auth: authClient });
+
+ try {
+ const createSheetResponse = await sheets.spreadsheets.create({
+ requestBody: {
+ properties: {
+ title: 'Text export'
+ }
+ }
+ });
+
+ const spreadsheetId = createSheetResponse.data.spreadsheetId;
+
+ await sheets.spreadsheets.values.update({
+ spreadsheetId: spreadsheetId as string,
+ range: 'Sheet1!A1',
+ requestBody: {
+ values: data.split('\n').map(e => {
+ return e.split(',')
+ })
+ }
+ });
+
+ return { ok: true }
+
+ } catch (error: any) {
+
+ console.error('Error creating Google Sheet from CSV:', error);
+
+ if (error.response && error.response.status === 401) {
+ return { error: 'Auth error, try to logout and login again' }
+ }
+
+ return { error: error.message.toString() }
+
+ }
+}
export default defineEventHandler(async event => {
@@ -69,6 +122,15 @@ export default defineEventHandler(async event => {
return content.join(',');
}).join('\n');
+
+
+ const isGoogle = getHeader(event, 'x-google-export');
+
+ if (isGoogle === 'true') {
+ const data = await exportToGoogle(result, userData.id);
+ return data;
+ }
+
return result;
} else {
return '';
diff --git a/shared/schema/UserSchema.ts b/shared/schema/UserSchema.ts
index be9509d..d312393 100644
--- a/shared/schema/UserSchema.ts
+++ b/shared/schema/UserSchema.ts
@@ -1,4 +1,4 @@
-import { model, Schema } from 'mongoose';
+import { model, Schema, Types } from 'mongoose';
export type TUser = {
email: string,
@@ -6,7 +6,15 @@ export type TUser = {
given_name: string,
locale: string,
picture: string,
- created_at: Date
+ created_at: Date,
+ google_tokens?: {
+ refresh_token?: string;
+ expiry_date?: number;
+ access_token?: string;
+ token_type?: string;
+ id_token?: string;
+ scope?: string;
+ }
}
const UserSchema = new Schema({
@@ -15,6 +23,14 @@ const UserSchema = new Schema({
given_name: String,
locale: String,
picture: String,
+ google_tokens: {
+ refresh_token: String,
+ expiry_date: Number,
+ access_token: String,
+ token_type: String,
+ id_token: String,
+ scope: String
+ },
created_at: { type: Date, default: () => Date.now() }
})