sdk changes
This commit is contained in:
@@ -1 +1,2 @@
|
||||
"use strict";(()=>{function y(u,t,e){return function(s,o,v){let m=`${u}${s}`,l,h={"mixan-client-id":t,"Content-Type":"application/json"};return e&&(h["mixan-client-secret"]=e),new Promise(p=>{let f=n=>{clearTimeout(l),fetch(m,{headers:h,method:"POST",body:JSON.stringify(o??{}),keepalive:!0,...v??{}}).then(async a=>{if(a.status!==200&&a.status!==202)return g(n,p);let w=await a.text();if(!w)return p(null);p(w)}).catch(()=>g(n,p))};function g(n,a){if(n>1)return a(null);l=setTimeout(()=>{f(n+1)},Math.pow(2,n)*500)}f(0)})}}var c=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=y(t.url,t.clientId,t.clientSecret)}init(t){this.state.properties=t??{}}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}async event(t,e){let i=await this.api("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()});this.options.setProfileId&&i&&this.options.setProfileId(i)}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}setUserProperty(t,e,i=!0){}timestamp(){return new Date().toISOString()}};var d=class extends c{constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}getTimezone(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{return}}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...s){let o=t.apply(this,s);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let e=history.replaceState;history.replaceState=function(...s){let o=e.apply(this,s);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),o},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){this.isServer()||super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer})}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
|
||||
"use strict";(()=>{function m(s){return Promise.all(Object.entries(s).map(async([t,e])=>[t,await e??""])).then(t=>Object.fromEntries(t))}function P(s){let t={"Content-Type":"application/json"};return{headers:t,async fetch(e,i,o){let a=`${s}${e}`,h,w=await m(t);return new Promise(p=>{let u=n=>{clearTimeout(h),fetch(a,{headers:w,method:"POST",body:JSON.stringify(i??{}),keepalive:!0,...o??{}}).then(async c=>{if(c.status!==200&&c.status!==202)return f(n,p);let g=await c.text();if(!g)return p(null);p(g)}).catch(()=>f(n,p))};function f(n,c){if(n>1)return c(null);h=setTimeout(()=>{u(n+1)},Math.pow(2,n)*500)}u(0)})}}}var l=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=P(t.url),this.api.headers["mixan-client-id"]=t.clientId,this.options.clientSecret&&(this.api.headers["mixan-client-secret"]=this.options.clientSecret)}init(t){this.state.properties=t??{}}setUser(t){this.api.fetch("/profile",{profileId:this.getProfileId(),...t,properties:{...this.state.properties,...t.properties}})}increment(t,e){this.api.fetch("/profile/increment",{property:t,value:e,profileId:this.getProfileId()})}decrement(t,e){this.api.fetch("/profile/decrement",{property:t,value:e,profileId:this.getProfileId()})}event(t,e){this.api.fetch("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()}).then(i=>{this.options.setProfileId&&i&&this.options.setProfileId(i)})}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}timestamp(){return new Date().toISOString()}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}};var d=class extends l{lastPath="";constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...o){let a=t.apply(this,o);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),a};let e=history.replaceState;history.replaceState=function(...o){let a=e.apply(this,o);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),a},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){this.isServer()||this.lastPath!==window.location.href&&(this.lastPath=window.location.href,super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer}))}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
|
||||
//# sourceMappingURL=cdn.global.js.map
|
||||
48
apps/sdk-api/scripts/get-referrers.ts
Normal file
48
apps/sdk-api/scripts/get-referrers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function transform(data: any) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const type in data) {
|
||||
for (const name in data[type]) {
|
||||
const domains = data[type][name].domains ?? [];
|
||||
for (const domain of domains) {
|
||||
obj[domain] = {
|
||||
type,
|
||||
name,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get document, or throw exception on error
|
||||
try {
|
||||
const data = await fetch(
|
||||
'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json'
|
||||
).then((res) => res.json());
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/referrers/index.ts'),
|
||||
[
|
||||
'// This file is generated by the script get-referrers.ts',
|
||||
'',
|
||||
'// The data is fetch from snowplow-referer-parser https://github.com/snowplow-referer-parser/referer-parser',
|
||||
`// The orginal referers.yml is based on Piwik's SearchEngines.php and Socials.php, copyright 2012 Matthieu Aubry and available under the GNU General Public License v3.`,
|
||||
'',
|
||||
`const referrers: Record<string, { type: string, name: string }> = ${JSON.stringify(
|
||||
transform(data)
|
||||
)} as const;`,
|
||||
'export default referrers;',
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -97,16 +97,14 @@ async function main() {
|
||||
const ua = properties.ua!;
|
||||
const uaInfo = parseUserAgent(ua);
|
||||
const salts = await getSalts();
|
||||
const profileId = generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
const [profileId, geo] = await Promise.all([
|
||||
generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
}),
|
||||
parseIp(ip),
|
||||
]);
|
||||
const geo = parseIp(ip);
|
||||
|
||||
const isNextEventNewSession =
|
||||
nextEvent &&
|
||||
|
||||
18
apps/sdk-api/scripts/test-ua.ts
Normal file
18
apps/sdk-api/scripts/test-ua.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
//@ts-nocheck
|
||||
|
||||
async function main() {
|
||||
const crypto = require('crypto');
|
||||
|
||||
function createHash(data, len) {
|
||||
return crypto
|
||||
.createHash('shake256', { outputLength: len })
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
console.log(createHash('foo', 2));
|
||||
// 1af9
|
||||
console.log(createHash('foo', 32));
|
||||
// 1af97f7818a28edf}
|
||||
}
|
||||
main();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseIp } from '@/utils/parseIp';
|
||||
import { parseReferrer } from '@/utils/parseReferrer';
|
||||
import { parseUserAgent } from '@/utils/parseUserAgent';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { omit } from 'ramda';
|
||||
@@ -56,33 +57,30 @@ export async function postEvent(
|
||||
let profileId: string | null = null;
|
||||
const projectId = request.projectId;
|
||||
const body = request.body;
|
||||
const { path, hash, query } = parsePath(
|
||||
body.properties?.path as string | undefined
|
||||
);
|
||||
const referrer = body.properties?.referrer as string | undefined;
|
||||
const { path, hash, query } = parsePath(body.properties?.path);
|
||||
const referrer = parseReferrer(body.properties?.referrer);
|
||||
const ip = getClientIp(request)!;
|
||||
const origin = request.headers.origin!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const uaInfo = parseUserAgent(ua);
|
||||
const salts = await getSalts();
|
||||
const currentProfileId = generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousProfileId = generateProfileId({
|
||||
salt: salts.previous,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
const [currentProfileId, previousProfileId, geo, eventsJobs] =
|
||||
await Promise.all([
|
||||
generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
}),
|
||||
generateProfileId({
|
||||
salt: salts.previous,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
}),
|
||||
parseIp(ip),
|
||||
eventsQueue.getJobs(['delayed']),
|
||||
]);
|
||||
const [geo, eventsJobs] = await Promise.all([
|
||||
parseIp(ip),
|
||||
eventsQueue.getJobs(['delayed']),
|
||||
]);
|
||||
|
||||
// find session_end job
|
||||
const sessionEndJobCurrentProfileId = findJobByPrefix(
|
||||
@@ -148,8 +146,9 @@ export async function postEvent(
|
||||
model: uaInfo.model,
|
||||
duration: 0,
|
||||
path: path,
|
||||
referrer,
|
||||
referrerName: referrer, // TODO
|
||||
referrer: referrer.url,
|
||||
referrerName: referrer.name,
|
||||
referrerType: referrer.type,
|
||||
};
|
||||
|
||||
const job = findJobByPrefix(eventsJobs, `event:${projectId}:${profileId}:`);
|
||||
@@ -171,7 +170,7 @@ export async function postEvent(
|
||||
duration,
|
||||
},
|
||||
});
|
||||
job.promote();
|
||||
await job.promote();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
291
apps/sdk-api/src/controllers/profile.controller.ts
Normal file
291
apps/sdk-api/src/controllers/profile.controller.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { parseIp } from '@/utils/parseIp';
|
||||
import { parseUserAgent } from '@/utils/parseUserAgent';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, mergeDeepRight, path } from 'ramda';
|
||||
import { getClientIp } from 'request-ip';
|
||||
|
||||
import { generateProfileId, toDots } from '@mixan/common';
|
||||
import type { IDBProfile, Profile } from '@mixan/db';
|
||||
import { db, getSalts } from '@mixan/db';
|
||||
import type {
|
||||
IncrementProfilePayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@mixan/types';
|
||||
|
||||
async function findProfile({
|
||||
profileId,
|
||||
ip,
|
||||
origin,
|
||||
ua,
|
||||
}: {
|
||||
profileId: string | null;
|
||||
ip: string;
|
||||
origin: string;
|
||||
ua: string;
|
||||
}) {
|
||||
const salts = await getSalts();
|
||||
const currentProfileId = generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousProfileId = generateProfileId({
|
||||
salt: salts.previous,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
const ids = [currentProfileId, previousProfileId];
|
||||
if (profileId) {
|
||||
ids.push(profileId);
|
||||
}
|
||||
|
||||
const profiles = await db.profile.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: ids,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return profiles.find((p) => {
|
||||
return (
|
||||
p.id === profileId ||
|
||||
p.id === currentProfileId ||
|
||||
p.id === previousProfileId
|
||||
);
|
||||
}) as IDBProfile | undefined;
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
request: FastifyRequest<{
|
||||
Body: UpdateProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const body = request.body;
|
||||
const profileId: string | null = body.profileId ?? null;
|
||||
const projectId = request.projectId;
|
||||
const ip = getClientIp(request)!;
|
||||
const origin = request.headers.origin ?? projectId;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const salts = await getSalts();
|
||||
const uaInfo = parseUserAgent(ua);
|
||||
const geo = await parseIp(ip);
|
||||
|
||||
if (profileId === null) {
|
||||
const currentProfileId = generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousProfileId = generateProfileId({
|
||||
salt: salts.previous,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
const profiles = await db.profile.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [currentProfileId, previousProfileId],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (profiles.length === 0) {
|
||||
const profile = await db.profile.create({
|
||||
data: {
|
||||
id: currentProfileId,
|
||||
external_id: body.id,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
email: body.email,
|
||||
avatar: body.avatar,
|
||||
project_id: projectId,
|
||||
properties: body.properties ?? {},
|
||||
// ...uaInfo,
|
||||
// ...geo,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.status(201).send(profile);
|
||||
}
|
||||
const currentProfile = profiles.find((p) => p.id === currentProfileId);
|
||||
const previousProfile = profiles.find((p) => p.id === previousProfileId);
|
||||
const profile = currentProfile ?? previousProfile;
|
||||
|
||||
if (profile) {
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: profile.id,
|
||||
},
|
||||
data: {
|
||||
external_id: body.id,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
email: body.email,
|
||||
avatar: body.avatar,
|
||||
properties: toDots(
|
||||
mergeDeepRight(
|
||||
profile.properties as Record<string, unknown>,
|
||||
body.properties ?? {}
|
||||
)
|
||||
),
|
||||
// ...uaInfo,
|
||||
// ...geo,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.status(200).send(profile.id);
|
||||
}
|
||||
|
||||
return reply.status(200).send();
|
||||
}
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: {
|
||||
id: profileId,
|
||||
},
|
||||
});
|
||||
|
||||
if (profile) {
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: profile.id,
|
||||
},
|
||||
data: {
|
||||
external_id: body.id,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
email: body.email,
|
||||
avatar: body.avatar,
|
||||
properties: toDots(
|
||||
mergeDeepRight(
|
||||
profile.properties as Record<string, unknown>,
|
||||
body.properties ?? {}
|
||||
)
|
||||
),
|
||||
// ...uaInfo,
|
||||
// ...geo,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.profile.create({
|
||||
data: {
|
||||
id: profileId,
|
||||
external_id: body.id,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
email: body.email,
|
||||
avatar: body.avatar,
|
||||
project_id: projectId,
|
||||
properties: body.properties ?? {},
|
||||
// ...uaInfo,
|
||||
// ...geo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(202).send(profileId);
|
||||
}
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const body = request.body;
|
||||
const profileId: string | null = body.profileId ?? null;
|
||||
const projectId = request.projectId;
|
||||
const ip = getClientIp(request)!;
|
||||
const origin = request.headers.origin ?? projectId;
|
||||
const ua = request.headers['user-agent']!;
|
||||
|
||||
const profile = await findProfile({
|
||||
ip,
|
||||
origin,
|
||||
ua,
|
||||
profileId,
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
}
|
||||
|
||||
const property = path(body.property.split('.'), profile.properties);
|
||||
|
||||
if (typeof property !== 'number' && typeof property !== 'undefined') {
|
||||
return reply.status(400).send('Not number');
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
body.property.split('.'),
|
||||
property ? property + body.value : body.value,
|
||||
profile.properties
|
||||
);
|
||||
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: profile.id,
|
||||
},
|
||||
data: {
|
||||
properties: profile.properties as any,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(202).send(profile.id);
|
||||
}
|
||||
|
||||
export async function decrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const body = request.body;
|
||||
const profileId: string | null = body.profileId ?? null;
|
||||
const projectId = request.projectId;
|
||||
const ip = getClientIp(request)!;
|
||||
const origin = request.headers.origin ?? projectId;
|
||||
const ua = request.headers['user-agent']!;
|
||||
|
||||
const profile = await findProfile({
|
||||
ip,
|
||||
origin,
|
||||
ua,
|
||||
profileId,
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
}
|
||||
|
||||
const property = path(body.property.split('.'), profile.properties);
|
||||
|
||||
if (typeof property !== 'number') {
|
||||
return reply.status(400).send('Not number');
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
body.property.split('.'),
|
||||
property ? property - body.value : -body.value,
|
||||
profile.properties
|
||||
);
|
||||
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: profile.id,
|
||||
},
|
||||
data: {
|
||||
properties: profile.properties as any,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(202).send(profile.id);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { redisPub } from '@mixan/redis';
|
||||
|
||||
import eventRouter from './routes/event.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import profileRouter from './routes/profile.router';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
@@ -29,6 +30,7 @@ const startServer = async () => {
|
||||
fastify.register(FastifySSEPlugin);
|
||||
fastify.decorateRequest('projectId', '');
|
||||
fastify.register(eventRouter, { prefix: '/event' });
|
||||
fastify.register(profileRouter, { prefix: '/profile' });
|
||||
fastify.register(liveRouter, { prefix: '/live' });
|
||||
fastify.setErrorHandler((error, request, reply) => {
|
||||
fastify.log.error(error);
|
||||
|
||||
2684
apps/sdk-api/src/referrers/index.ts
Normal file
2684
apps/sdk-api/src/referrers/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
5
apps/sdk-api/src/referrers/referrers.readme.md
Normal file
5
apps/sdk-api/src/referrers/referrers.readme.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Snowplow Referer Parser
|
||||
|
||||
The file index.ts in this dir is generated from snowplows referer database [Snowplow Referer Parser](https://github.com/snowplow-referer-parser/referer-parser).
|
||||
|
||||
The orginal [referers.yml](https://github.com/snowplow-referer-parser/referer-parser/blob/master/resources/referers.yml) is based on Piwik's SearchEngines.php and Socials.php, copyright 2012 Matthieu Aubry and available under the GNU General Public License v3.
|
||||
37
apps/sdk-api/src/routes/profile.router.ts
Normal file
37
apps/sdk-api/src/routes/profile.router.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as controller from '@/controllers/profile.controller';
|
||||
import { validateSdkRequest } from '@/utils/auth';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||
fastify.addHook('preHandler', (req, reply, done) => {
|
||||
validateSdkRequest(req.headers)
|
||||
.then((projectId) => {
|
||||
req.projectId = projectId;
|
||||
done();
|
||||
})
|
||||
.catch((e) => {
|
||||
reply.status(401).send();
|
||||
});
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: controller.updateProfile,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/increment',
|
||||
handler: controller.incrementProfileProperty,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/decrement',
|
||||
handler: controller.decrementProfileProperty,
|
||||
});
|
||||
done();
|
||||
};
|
||||
|
||||
export default eventRouter;
|
||||
@@ -1,12 +1,34 @@
|
||||
export async function parseIp(ip: string) {
|
||||
interface RemoteIpLookupResponse {
|
||||
country: string | undefined;
|
||||
city: string | undefined;
|
||||
stateprov: string | undefined;
|
||||
continent: string | undefined;
|
||||
}
|
||||
|
||||
interface GeoLocation {
|
||||
country: string | undefined;
|
||||
city: string | undefined;
|
||||
region: string | undefined;
|
||||
continent: string | undefined;
|
||||
}
|
||||
|
||||
const geo: GeoLocation = {
|
||||
country: undefined,
|
||||
city: undefined,
|
||||
region: undefined,
|
||||
continent: undefined,
|
||||
};
|
||||
|
||||
const ignore = ['127.0.0.1', '::1'];
|
||||
|
||||
export async function parseIp(ip?: string): Promise<GeoLocation> {
|
||||
if (!ip || ignore.includes(ip)) {
|
||||
return geo;
|
||||
}
|
||||
|
||||
try {
|
||||
const geo = await fetch(`${process.env.GEO_IP_HOST}/${ip}`);
|
||||
const res = (await geo.json()) as {
|
||||
country: string | undefined;
|
||||
city: string | undefined;
|
||||
stateprov: string | undefined;
|
||||
continent: string | undefined;
|
||||
};
|
||||
const res = (await geo.json()) as RemoteIpLookupResponse;
|
||||
|
||||
return {
|
||||
country: res.country,
|
||||
@@ -16,12 +38,6 @@ export async function parseIp(ip: string) {
|
||||
};
|
||||
} catch (e) {
|
||||
console.log('Failed to parse ip', e);
|
||||
|
||||
return {
|
||||
country: undefined,
|
||||
city: undefined,
|
||||
region: undefined,
|
||||
continent: undefined,
|
||||
};
|
||||
return geo;
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/sdk-api/src/utils/parseReferrer.ts
Normal file
15
apps/sdk-api/src/utils/parseReferrer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import referrers from '../referrers';
|
||||
|
||||
export function parseReferrer(url?: string) {
|
||||
const { hostname } = new URL(url || '');
|
||||
const match = referrers[hostname];
|
||||
|
||||
console.log('Parsing referrer', url);
|
||||
console.log('Match', match);
|
||||
|
||||
return {
|
||||
name: match?.name ?? '',
|
||||
type: match?.type ?? 'unknown',
|
||||
url: url ?? '',
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
"use strict";(()=>{function y(u,t,e){return function(s,o,v){let m=`${u}${s}`,l,h={"mixan-client-id":t,"Content-Type":"application/json"};return e&&(h["mixan-client-secret"]=e),new Promise(a=>{let f=n=>{clearTimeout(l),fetch(m,{headers:h,method:"POST",body:JSON.stringify(o??{}),keepalive:!0,...v??{}}).then(async p=>{if(p.status!==200)return g(n,a);let w=await p.json();if(!w)return a(null);a(w)}).catch(()=>g(n,a))};function g(n,p){if(n>1)return p(null);l=setTimeout(()=>{f(n+1)},Math.pow(2,n)*500)}f(0)})}}var c=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=y(t.url,t.clientId,t.clientSecret)}init(t){this.state.properties=t??{}}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}async event(t,e){let i=await this.api("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()});this.options.setProfileId&&i&&this.options.setProfileId(i)}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}setUserProperty(t,e,i=!0){}timestamp(){return new Date().toISOString()}};var d=class extends c{constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}getTimezone(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{return}}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...s){let o=t.apply(this,s);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let e=history.replaceState;history.replaceState=function(...s){let o=e.apply(this,s);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),o},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView())}screenView(t){this.isServer()||super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer})}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
|
||||
//# sourceMappingURL=op.js.map
|
||||
"use strict";(()=>{function m(s){return Promise.all(Object.entries(s).map(async([t,e])=>[t,await e??""])).then(t=>Object.fromEntries(t))}function P(s){let t={"Content-Type":"application/json"};return{headers:t,async fetch(e,i,o){let a=`${s}${e}`,h,w=await m(t);return new Promise(p=>{let u=n=>{clearTimeout(h),fetch(a,{headers:w,method:"POST",body:JSON.stringify(i??{}),keepalive:!0,...o??{}}).then(async c=>{if(c.status!==200&&c.status!==202)return f(n,p);let g=await c.text();if(!g)return p(null);p(g)}).catch(()=>f(n,p))};function f(n,c){if(n>1)return c(null);h=setTimeout(()=>{u(n+1)},Math.pow(2,n)*500)}u(0)})}}}var l=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=P(t.url),this.api.headers["mixan-client-id"]=t.clientId,this.options.clientSecret&&(this.api.headers["mixan-client-secret"]=this.options.clientSecret)}init(t){this.state.properties=t??{}}setUser(t){this.api.fetch("/profile",{profileId:this.getProfileId(),...t,properties:{...this.state.properties,...t.properties}})}increment(t,e){this.api.fetch("/profile/increment",{property:t,value:e,profileId:this.getProfileId()})}decrement(t,e){this.api.fetch("/profile/decrement",{property:t,value:e,profileId:this.getProfileId()})}event(t,e){this.api.fetch("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()}).then(i=>{this.options.setProfileId&&i&&this.options.setProfileId(i)})}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}timestamp(){return new Date().toISOString()}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}};var d=class extends l{lastPath="";constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...o){let a=t.apply(this,o);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),a};let e=history.replaceState;history.replaceState=function(...o){let a=e.apply(this,o);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),a},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){this.isServer()||this.lastPath!==window.location.href&&(this.lastPath=window.location.href,super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer}))}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
|
||||
//# sourceMappingURL=cdn.global.js.map
|
||||
@@ -7,8 +7,8 @@ export default function Document() {
|
||||
<script
|
||||
async
|
||||
src="/op.js"
|
||||
data-url="https://api.openpanel.dev"
|
||||
data-client-id="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
data-url="http://localhost:3333"
|
||||
data-client-id="0acce97f-1126-4439-b7ee-5d384e2fc94b"
|
||||
data-track-screen-views="1"
|
||||
data-track-outgoing-links="1"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mixan } from '@/analytics';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Test() {
|
||||
@@ -7,22 +6,33 @@ export default function Test() {
|
||||
<Link href="/">Home</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
mixan.setUser({
|
||||
// @ts-expect-error
|
||||
window.openpanel.setUser({
|
||||
first_name: 'John',
|
||||
});
|
||||
mixan.setUser({
|
||||
last_name: 'Doe',
|
||||
});
|
||||
mixan.setUser({
|
||||
email: 'john.doe@gmail.com',
|
||||
});
|
||||
mixan.setUser({
|
||||
id: '1234',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Set user
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// @ts-expect-error
|
||||
window.openpanel.increment('app_open', 1);
|
||||
}}
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// @ts-expect-error
|
||||
window.openpanel.decrement('app_open', 1);
|
||||
}}
|
||||
>
|
||||
Decrement
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.clear();
|
||||
@@ -44,7 +54,8 @@ export default function Test() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
mixan.clear();
|
||||
// @ts-expect-error
|
||||
window.openpanel.clear();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"cmdk": "^0.2.0",
|
||||
"hamburger-react": "^2.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.323.0",
|
||||
"mathjs": "^12.3.0",
|
||||
@@ -84,6 +85,7 @@
|
||||
"@mixan/tsconfig": "workspace:*",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/react": "^18.2.20",
|
||||
|
||||
@@ -198,22 +198,19 @@ export default function OverviewMetrics() {
|
||||
return (
|
||||
<Sheet>
|
||||
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
|
||||
<ReportRange
|
||||
size="sm"
|
||||
value={range}
|
||||
onChange={(value) => setRange(value)}
|
||||
/>
|
||||
<div className="flex-wrap flex gap-2">
|
||||
<LiveCounter initialCount={0} />
|
||||
<OverviewFiltersButtons />
|
||||
<div className="flex gap-2">
|
||||
<ReportRange value={range} onChange={(value) => setRange(value)} />
|
||||
<SheetTrigger asChild>
|
||||
<Button size="sm" variant="cta" icon={FilterIcon}>
|
||||
<Button variant="outline" responsive icon={FilterIcon}>
|
||||
Filters
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<LiveCounter initialCount={0} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" icon={Globe2Icon}>
|
||||
<Button icon={Globe2Icon} responsive>
|
||||
Public
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -236,6 +233,10 @@ export default function OverviewMetrics() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
|
||||
<div className="p-4 flex gap-2 flex-wrap">
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<div className="p-4 grid gap-4 grid-cols-6">
|
||||
{reports.map((report, index) => (
|
||||
<button
|
||||
@@ -276,7 +277,7 @@ export default function OverviewMetrics() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetContent className="!max-w-lg w-full" side="left">
|
||||
<SheetContent className="!max-w-lg w-full" side="right">
|
||||
<OverviewFilters />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -13,9 +13,8 @@ import { ProfileListItem } from './profile-list-item';
|
||||
|
||||
interface ListProfilesProps {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
||||
export function ListProfiles({ projectId }: ListProfilesProps) {
|
||||
const [query, setQuery] = useQueryState('q');
|
||||
const pagination = usePagination();
|
||||
const profilesQuery = api.profile.list.useQuery(
|
||||
|
||||
@@ -13,10 +13,9 @@ export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
await getExists(organizationId, projectId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Events" organizationSlug={organizationId}>
|
||||
<ListProfiles projectId={projectId} organizationId={organizationId} />
|
||||
<ListProfiles projectId={projectId} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { getOrganizationByProjectId } from '@/server/services/organization.service';
|
||||
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||
import { getProjectById } from '@/server/services/project.service';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { projectId } }: PageProps) {
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
const project = await getProjectById(projectId);
|
||||
const organization = await getOrganizationByProjectId(projectId);
|
||||
const organization = await getOrganizationBySlug(organizationId);
|
||||
return (
|
||||
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
@@ -1,6 +0,0 @@
|
||||
import { authOptions } from '@/server/auth';
|
||||
import NextAuth from 'next-auth/next';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { Label } from '../ui/label';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
export function OverviewFiltersButtons() {
|
||||
@@ -17,111 +12,166 @@ export function OverviewFiltersButtons() {
|
||||
{options.referrer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setReferrer(null)}
|
||||
>
|
||||
{options.referrer}
|
||||
<span className="mr-1">Referrer is</span>
|
||||
<strong>{options.referrer}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.device && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setDevice(null)}
|
||||
>
|
||||
{options.device}
|
||||
<span className="mr-1">Device is</span>
|
||||
<strong>{options.device}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.page && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setPage(null)}
|
||||
>
|
||||
{options.page}
|
||||
<span className="mr-1">Page is</span>
|
||||
<strong>{options.page}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.utmSource && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setUtmSource(null)}
|
||||
>
|
||||
{options.utmSource}
|
||||
<span className="mr-1">Utm Source is</span>
|
||||
<strong>{options.utmSource}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.utmMedium && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setUtmMedium(null)}
|
||||
>
|
||||
{options.utmMedium}
|
||||
<span className="mr-1">Utm Medium is</span>
|
||||
<strong>{options.utmMedium}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.utmCampaign && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setUtmCampaign(null)}
|
||||
>
|
||||
{options.utmCampaign}
|
||||
<span className="mr-1">Utm Campaign is</span>
|
||||
<strong>{options.utmCampaign}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.utmTerm && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setUtmTerm(null)}
|
||||
>
|
||||
{options.utmTerm}
|
||||
<span className="mr-1">Utm Term is</span>
|
||||
<strong>{options.utmTerm}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.utmContent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setUtmContent(null)}
|
||||
>
|
||||
{options.utmContent}
|
||||
<span className="mr-1">Utm Content is</span>
|
||||
<strong>{options.utmContent}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.country && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setCountry(null)}
|
||||
>
|
||||
{options.country}
|
||||
<span className="mr-1">Country is</span>
|
||||
<strong>{options.country}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.region && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setRegion(null)}
|
||||
>
|
||||
{options.region}
|
||||
<span className="mr-1">Region is</span>
|
||||
<strong>{options.region}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.city && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setCity(null)}
|
||||
>
|
||||
{options.city}
|
||||
<span className="mr-1">City is</span>
|
||||
<strong>{options.city}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.browser && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setBrowser(null)}
|
||||
>
|
||||
<span className="mr-1">Browser is</span>
|
||||
<strong>{options.browser}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.browserVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setBrowserVersion(null)}
|
||||
>
|
||||
<span className="mr-1">Browser Version is</span>
|
||||
<strong>{options.browserVersion}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.os && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setOS(null)}
|
||||
>
|
||||
<span className="mr-1">OS is</span>
|
||||
<strong>{options.os}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.osVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setOSVersion(null)}
|
||||
>
|
||||
<span className="mr-1">OS Version is</span>
|
||||
<strong>{options.osVersion}</strong>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,8 +11,20 @@ import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopDevices() {
|
||||
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
|
||||
useOverviewOptions();
|
||||
const {
|
||||
filters,
|
||||
interval,
|
||||
range,
|
||||
previous,
|
||||
setBrowser,
|
||||
setBrowserVersion,
|
||||
browser,
|
||||
browserVersion,
|
||||
setOS,
|
||||
setOSVersion,
|
||||
os,
|
||||
osVersion,
|
||||
} = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
||||
devices: {
|
||||
title: 'Top devices',
|
||||
@@ -180,19 +192,22 @@ export default function OverviewTopDevices() {
|
||||
{...widget.chart}
|
||||
previous={false}
|
||||
onClick={(item) => {
|
||||
// switch (widget.key) {
|
||||
// case 'browser':
|
||||
// setWidget('browser_version');
|
||||
// // setCountry(item.name);
|
||||
// break;
|
||||
// case 'regions':
|
||||
// setWidget('cities');
|
||||
// setRegion(item.name);
|
||||
// break;
|
||||
// case 'cities':
|
||||
// setCity(item.name);
|
||||
// break;
|
||||
// }
|
||||
switch (widget.key) {
|
||||
case 'browser':
|
||||
setWidget('browser_version');
|
||||
setBrowser(item.name);
|
||||
break;
|
||||
case 'browser_version':
|
||||
setBrowserVersion(item.name);
|
||||
break;
|
||||
case 'os':
|
||||
setWidget('os_version');
|
||||
setOS(item.name);
|
||||
break;
|
||||
case 'os_version':
|
||||
setOSVersion(item.name);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Children, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useThrottle } from '@/hooks/useThrottle';
|
||||
import { cn } from '@/utils/cn';
|
||||
import throttle from 'lodash.throttle';
|
||||
import { ChevronsUpDownIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import type { WidgetHeadProps } from '../Widget';
|
||||
import { WidgetHead as WidgetHeadBase } from '../Widget';
|
||||
|
||||
@@ -14,14 +26,98 @@ export function WidgetHead({ className, ...props }: WidgetHeadProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetButtons({ className, ...props }: WidgetHeadProps) {
|
||||
export function WidgetButtons({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WidgetHeadProps) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const sizes = useRef<number[]>([]);
|
||||
const [slice, setSlice] = useState(Children.count(children) - 1);
|
||||
const gap = 8;
|
||||
|
||||
const handleResize = useThrottle(() => {
|
||||
if (container.current) {
|
||||
if (sizes.current.length === 0) {
|
||||
// Get buttons
|
||||
const buttons: HTMLButtonElement[] = Array.from(
|
||||
container.current.querySelectorAll(`button`)
|
||||
);
|
||||
// Get sizes and cache them
|
||||
sizes.current = buttons.map(
|
||||
(button) => Math.ceil(button.offsetWidth) + gap
|
||||
);
|
||||
}
|
||||
const containerWidth = container.current.offsetWidth;
|
||||
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
|
||||
const moreWidth = (last(sizes.current) ?? 0) + gap;
|
||||
|
||||
if (buttonsWidth > containerWidth) {
|
||||
const res = sizes.current.reduce(
|
||||
(acc, size, index) => {
|
||||
if (acc.size + size + moreWidth > containerWidth) {
|
||||
return { index: acc.index, size: acc.size + size };
|
||||
}
|
||||
return { index, size: acc.size + size };
|
||||
},
|
||||
{ index: 0, size: 0 }
|
||||
);
|
||||
|
||||
setSlice(res.index);
|
||||
} else {
|
||||
setSlice(sizes.current.length - 1);
|
||||
}
|
||||
}
|
||||
}, 30);
|
||||
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [handleResize, children]);
|
||||
|
||||
const hidden = '!opacity-0 absolute pointer-events-none';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={container}
|
||||
className={cn(
|
||||
'flex gap-2 [&_button]:text-xs [&_button]:opacity-50 [&_button.active]:opacity-100',
|
||||
'flex-1 justify-end transition-opacity flex flex-wrap [&_button]:text-xs [&_button]:opacity-50 [&_button]:whitespace-nowrap [&_button.active]:opacity-100',
|
||||
className
|
||||
)}
|
||||
style={{ gap }}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{Children.map(children, (child, index) => {
|
||||
return (
|
||||
<div className={cn('flex', slice < index ? hidden : 'opacity-100')}>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 select-none',
|
||||
sizes.current.length - 1 === slice ? hidden : 'opacity-50'
|
||||
)}
|
||||
>
|
||||
More <ChevronsUpDownIcon size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="[&_button]:w-full">
|
||||
<DropdownMenuGroup>
|
||||
{Children.map(children, (child, index) => {
|
||||
if (index <= slice) {
|
||||
return null;
|
||||
}
|
||||
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,15 +34,12 @@ export function useOverviewOptions() {
|
||||
'referrer',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [device, setDevice] = useQueryState(
|
||||
'device',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
// Sources
|
||||
const [utmSource, setUtmSource] = useQueryState(
|
||||
'utm_source',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
@@ -64,6 +61,7 @@ export function useOverviewOptions() {
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
// Geo
|
||||
const [country, setCountry] = useQueryState(
|
||||
'country',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
@@ -77,6 +75,28 @@ export function useOverviewOptions() {
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
//
|
||||
const [device, setDevice] = useQueryState(
|
||||
'device',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [browser, setBrowser] = useQueryState(
|
||||
'browser',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [browserVersion, setBrowserVersion] = useQueryState(
|
||||
'browser_version',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [os, setOS] = useQueryState(
|
||||
'os',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [osVersion, setOSVersion] = useQueryState(
|
||||
'os_version',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const filters: IChartInput['events'][number]['filters'] = [];
|
||||
if (referrer) {
|
||||
@@ -178,6 +198,42 @@ export function useOverviewOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
filters.push({
|
||||
id: 'browser',
|
||||
operator: 'is',
|
||||
name: 'browser',
|
||||
value: [browser],
|
||||
});
|
||||
}
|
||||
|
||||
if (browserVersion) {
|
||||
filters.push({
|
||||
id: 'browser_version',
|
||||
operator: 'is',
|
||||
name: 'browser_version',
|
||||
value: [browserVersion],
|
||||
});
|
||||
}
|
||||
|
||||
if (os) {
|
||||
filters.push({
|
||||
id: 'os',
|
||||
operator: 'is',
|
||||
name: 'os',
|
||||
value: [os],
|
||||
});
|
||||
}
|
||||
|
||||
if (osVersion) {
|
||||
filters.push({
|
||||
id: 'os_version',
|
||||
operator: 'is',
|
||||
name: 'os_version',
|
||||
value: [osVersion],
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [
|
||||
referrer,
|
||||
@@ -191,6 +247,10 @@ export function useOverviewOptions() {
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
browser,
|
||||
browserVersion,
|
||||
os,
|
||||
osVersion,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -202,8 +262,6 @@ export function useOverviewOptions() {
|
||||
setMetric,
|
||||
referrer,
|
||||
setReferrer,
|
||||
device,
|
||||
setDevice,
|
||||
page,
|
||||
setPage,
|
||||
|
||||
@@ -230,5 +288,17 @@ export function useOverviewOptions() {
|
||||
setRegion,
|
||||
city,
|
||||
setCity,
|
||||
|
||||
// Tech
|
||||
device,
|
||||
setDevice,
|
||||
browser,
|
||||
setBrowser,
|
||||
browserVersion,
|
||||
setBrowserVersion,
|
||||
os,
|
||||
setOS,
|
||||
osVersion,
|
||||
setOSVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
import type { IChartSerie } from '@/server/api/routers/chart';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
export interface ChartContextType extends IChartInput {
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
onClick?: (item: any) => void;
|
||||
onClick?: (item: IChartSerie) => void;
|
||||
}
|
||||
|
||||
type ChartProviderProps = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { NOT_SET_VALUE } from '@/utils/constants';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
@@ -51,10 +52,15 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
</div>
|
||||
)}
|
||||
{series.map((serie, index) => {
|
||||
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className="py-2 flex flex-1 w-full gap-4 items-center"
|
||||
className={cn(
|
||||
'py-2 flex flex-1 w-full gap-4 items-center',
|
||||
isClickable && 'cursor-pointer hover:bg-gray-100'
|
||||
)}
|
||||
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
|
||||
>
|
||||
<div className="flex-1 break-all">{serie.name}</div>
|
||||
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
|
||||
|
||||
@@ -6,7 +6,6 @@ import { api } from '@/app/_trpc/client';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
||||
import { ChartEmpty } from './ChartEmpty';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ButtonProps
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: LucideIcon;
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
@@ -57,6 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
loading,
|
||||
disabled,
|
||||
icon,
|
||||
responsive,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -71,9 +73,19 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-4 w-4 mr-2',
|
||||
responsive && 'mr-0 sm:mr-2',
|
||||
loading && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{responsive ? (
|
||||
<span className="hidden sm:block">{children}</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
15
apps/web/src/hooks/useThrottle.ts
Normal file
15
apps/web/src/hooks/useThrottle.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import throttle from 'lodash.throttle';
|
||||
|
||||
export function useThrottle(cb: () => void, delay: number) {
|
||||
const options = { leading: true, trailing: false }; // add custom lodash options
|
||||
const cbRef = useRef(cb);
|
||||
// use mutable ref to make useCallback/throttle not depend on `cb` dep
|
||||
useEffect(() => {
|
||||
cbRef.current = cb;
|
||||
});
|
||||
return useCallback(
|
||||
throttle(() => cbRef.current(), delay, options),
|
||||
[delay]
|
||||
);
|
||||
}
|
||||
18
apps/web/src/pages/api/cron/cache/update.tsx
vendored
18
apps/web/src/pages/api/cron/cache/update.tsx
vendored
@@ -1,18 +0,0 @@
|
||||
import * as cache from '@/server/cache';
|
||||
import { db } from '@/server/db';
|
||||
import { getUniqueEvents } from '@/server/services/event.service';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const projects = await db.project.findMany();
|
||||
|
||||
for (const project of projects) {
|
||||
const events = await getUniqueEvents({ projectId: project.id });
|
||||
cache.set(`events_${project.id}`, 1000 * 60 * 60 * 24, events);
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { eventsQueue } from '@mixan/queue';
|
||||
import type { BatchPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: BatchPayload[];
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
if (req.method == 'OPTIONS') {
|
||||
await validateSdkRequest(req, res);
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
const projectId = await validateSdkRequest(req, res);
|
||||
|
||||
await eventsQueue.add('batch', {
|
||||
projectId,
|
||||
payload: req.body,
|
||||
});
|
||||
|
||||
res.status(200).json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { db } from '@/server/db';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import type { EventPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: EventPayload[];
|
||||
}
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
if (req.method == 'OPTIONS') {
|
||||
await validateSdkRequest(req, res);
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
const projectId = await validateSdkRequest(req, res);
|
||||
|
||||
await db.event.createMany({
|
||||
data: req.body.map((event) => ({
|
||||
name: event.name,
|
||||
properties: event.properties,
|
||||
createdAt: event.time,
|
||||
project_id: projectId,
|
||||
profile_id: event.profileId,
|
||||
})),
|
||||
});
|
||||
|
||||
res.status(200).json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import { tickProfileProperty } from '@/server/services/profile.service';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import type { ProfileIncrementPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: ProfileIncrementPayload;
|
||||
}
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
if (req.method == 'OPTIONS') {
|
||||
await validateSdkRequest(req, res);
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
if (req.method !== 'PUT') {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
await validateSdkRequest(req, res);
|
||||
|
||||
const profileId = req.query.profileId as string;
|
||||
|
||||
await tickProfileProperty({
|
||||
name: req.body.name,
|
||||
tick: -Math.abs(req.body.value),
|
||||
profileId,
|
||||
});
|
||||
|
||||
res.status(200).json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import { tickProfileProperty } from '@/server/services/profile.service';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import type { ProfileIncrementPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: ProfileIncrementPayload;
|
||||
}
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
if (req.method == 'OPTIONS') {
|
||||
await validateSdkRequest(req, res);
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
if (req.method !== 'PUT') {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
await validateSdkRequest(req, res);
|
||||
|
||||
const profileId = req.query.profileId as string;
|
||||
|
||||
await tickProfileProperty({
|
||||
name: req.body.name,
|
||||
tick: req.body.value,
|
||||
profileId,
|
||||
});
|
||||
|
||||
res.status(200).json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { db } from '@/server/db';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import { getProfile } from '@/server/services/profile.service';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import type { ProfilePayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: ProfilePayload;
|
||||
}
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
if (req.method == 'OPTIONS') {
|
||||
await validateSdkRequest(req, res);
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
if (req.method !== 'PUT' && req.method !== 'POST') {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
await validateSdkRequest(req, res);
|
||||
|
||||
const profileId = req.query.profileId as string;
|
||||
const profile = await getProfile(profileId);
|
||||
|
||||
const { body } = req;
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: profileId,
|
||||
},
|
||||
data: {
|
||||
external_id: body.id,
|
||||
email: body.email,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
avatar: body.avatar,
|
||||
properties: {
|
||||
...(typeof profile.properties === 'object'
|
||||
? profile.properties ?? {}
|
||||
: {}),
|
||||
...(body.properties ?? {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { db } from '@/server/db';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import randomAnimalName from 'random-animal-name';
|
||||
|
||||
import type { CreateProfileResponse, ProfilePayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: ProfilePayload;
|
||||
}
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
if (req.method == 'OPTIONS') {
|
||||
await validateSdkRequest(req, res);
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
const projectId = await validateSdkRequest(req, res);
|
||||
|
||||
// Providing an `ID` is deprecated, should be removed in the future
|
||||
const profileId = 'id' in req.body ? req.body.id : undefined;
|
||||
const { properties } = req.body ?? {};
|
||||
|
||||
const profile = await db.profile.create({
|
||||
data: {
|
||||
id: profileId,
|
||||
external_id: null,
|
||||
email: null,
|
||||
first_name: randomAnimalName(),
|
||||
last_name: null,
|
||||
avatar: null,
|
||||
properties: {
|
||||
...(properties ?? {}),
|
||||
},
|
||||
project_id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const response: CreateProfileResponse = { id: profile.id };
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { eventsQueue } from '@mixan/queue';
|
||||
import type { BatchPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: BatchPayload[];
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function handler(req: Request, res: NextApiResponse) {
|
||||
eventsQueue.add('batch', {
|
||||
payload: [
|
||||
{
|
||||
type: 'event',
|
||||
payload: {
|
||||
profileId: 'f8235c6a-c720-4f38-8f6c-b6b7d31e16db',
|
||||
name: 'test',
|
||||
properties: {},
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
projectId: 'b725eadb-a1fe-4be8-bf0b-9d9bfa6aac12',
|
||||
});
|
||||
res.status(200).json({ status: 'ok' });
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
IGetChartDataInput,
|
||||
IInterval,
|
||||
} from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import { alphabetIds, NOT_SET_VALUE } from '@/utils/constants';
|
||||
import { round } from '@/utils/math';
|
||||
import * as mathjs from 'mathjs';
|
||||
import { sort } from 'ramda';
|
||||
@@ -207,7 +207,7 @@ export async function getChartData(payload: IGetChartDataInput) {
|
||||
(acc, item) => {
|
||||
// item.label can be null when using breakdowns on a property
|
||||
// that doesn't exist on all events
|
||||
const label = item.label?.trim() || '(not set)';
|
||||
const label = item.label?.trim() || NOT_SET_VALUE;
|
||||
if (label) {
|
||||
if (acc[label]) {
|
||||
acc[label]?.push(item);
|
||||
|
||||
@@ -29,19 +29,21 @@ interface Metrics {
|
||||
};
|
||||
}
|
||||
|
||||
interface FinalChart {
|
||||
events: IChartInput['events'];
|
||||
series: {
|
||||
name: string;
|
||||
event: IChartEvent;
|
||||
metrics: Metrics;
|
||||
data: {
|
||||
date: string;
|
||||
count: number;
|
||||
label: string | null;
|
||||
previous: PreviousValue;
|
||||
}[];
|
||||
export interface IChartSerie {
|
||||
name: string;
|
||||
event: IChartEvent;
|
||||
metrics: Metrics;
|
||||
data: {
|
||||
date: string;
|
||||
count: number;
|
||||
label: string | null;
|
||||
previous: PreviousValue;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FinalChart {
|
||||
events: IChartInput['events'];
|
||||
series: IChartSerie[];
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import { db } from '@/server/db';
|
||||
import { hashPassword } from '@/server/services/hash.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { hashPassword } from '@mixan/common';
|
||||
|
||||
export const clientRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
|
||||
@@ -29,6 +29,8 @@ export const eventRouter = createTRPCRouter({
|
||||
sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`;
|
||||
}
|
||||
|
||||
sb.orderBy.created_at = 'created_at DESC';
|
||||
|
||||
return (await chQuery<IDBEvent>(getSql())).map(transformEvent);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
||||
|
||||
const keyLength = 32;
|
||||
/**
|
||||
* Has a password or a secret with a password hashing algorithm (scrypt)
|
||||
* @param {string} password
|
||||
* @returns {string} The salt+hash
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// generate random 16 bytes long salt - recommended by NodeJS Docs
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
scrypt(password, salt, keyLength, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
// derivedKey is of type Buffer
|
||||
resolve(`${salt}.${derivedKey.toString('hex')}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a plain text password with a salt+hash password
|
||||
* @param {string} password The plain text password
|
||||
* @param {string} hash The hash+salt to check against
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, hashKey] = hash.split('.');
|
||||
// we need to pass buffer values to timingSafeEqual
|
||||
const hashKeyBuff = Buffer.from(hashKey!, 'hex');
|
||||
scrypt(password, salt!, keyLength, (err, derivedKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
// compare the new supplied password with the hashed password using timeSafeEqual
|
||||
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export const NOT_SET_VALUE = '(not set)';
|
||||
|
||||
export const operators = {
|
||||
is: 'Is',
|
||||
isNot: 'Is not',
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './src/crypto';
|
||||
export * from './src/profileId';
|
||||
export * from './src/date';
|
||||
export * from './src/object';
|
||||
export * from './src/names';
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ramda": "^0.29.1"
|
||||
"ramda": "^0.29.1",
|
||||
"unique-names-generator": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mixan/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
||||
import {
|
||||
createHash as cryptoCreateHash,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
timingSafeEqual,
|
||||
} from 'crypto';
|
||||
|
||||
export function generateSalt() {
|
||||
return randomBytes(16).toString('hex');
|
||||
@@ -11,12 +16,11 @@ export function generateSalt() {
|
||||
*/
|
||||
export async function hashPassword(
|
||||
password: string,
|
||||
_salt?: string,
|
||||
keyLength = 32
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// generate random 16 bytes long salt - recommended by NodeJS Docs
|
||||
const salt = _salt || generateSalt();
|
||||
const salt = generateSalt();
|
||||
scrypt(password, salt, keyLength, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
// derivedKey is of type Buffer
|
||||
@@ -49,3 +53,9 @@ export async function verifyPassword(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createHash(data: string, len: number) {
|
||||
return cryptoCreateHash('shake256', { outputLength: len })
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
18
packages/common/src/names.ts
Normal file
18
packages/common/src/names.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { animals, names, uniqueNamesGenerator } from 'unique-names-generator';
|
||||
|
||||
export function randomName() {
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [names, animals],
|
||||
length: 2,
|
||||
style: 'capital',
|
||||
separator: ' ',
|
||||
});
|
||||
}
|
||||
|
||||
export function randomSplitName() {
|
||||
const [firstName, lastName] = randomName().split(' ');
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hashPassword } from './crypto';
|
||||
import { createHash } from './crypto';
|
||||
|
||||
interface GenerateProfileIdOptions {
|
||||
salt: string;
|
||||
@@ -7,11 +7,11 @@ interface GenerateProfileIdOptions {
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export async function generateProfileId({
|
||||
export function generateProfileId({
|
||||
salt,
|
||||
ua,
|
||||
ip,
|
||||
origin,
|
||||
}: GenerateProfileIdOptions) {
|
||||
return await hashPassword(`${ua}:${ip}:${origin}`, salt, 8);
|
||||
return createHash(`${ua}:${ip}:${origin}:${salt}`, 16);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,10 @@ CREATE TABLE openpanel.events (
|
||||
`name` String,
|
||||
`profile_id` String,
|
||||
`project_id` String,
|
||||
-- the route
|
||||
`path` String,
|
||||
`utm_source` String,
|
||||
`utm_medium` String,
|
||||
`utm_campaign` String,
|
||||
`utm_term` String,
|
||||
`utm_content` String,
|
||||
`referrer` String,
|
||||
`referrer_name` String,
|
||||
`referrer_type` String,
|
||||
`duration` UInt64,
|
||||
`properties` Map(String, String),
|
||||
`created_at` DateTime64(3),
|
||||
|
||||
@@ -97,24 +97,23 @@ async function main() {
|
||||
const ua = event.properties.ua as string;
|
||||
const uaInfo = parseUserAgent(ua);
|
||||
const salts = await getSalts();
|
||||
const currentProfileId = generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousProfileId = generateProfileId({
|
||||
salt: salts.previous,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
const [currentProfileId, previousProfileId, geo, eventsJobs] =
|
||||
await Promise.all([
|
||||
generateProfileId({
|
||||
salt: salts.current,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
}),
|
||||
generateProfileId({
|
||||
salt: salts.previous,
|
||||
origin,
|
||||
ip,
|
||||
ua,
|
||||
}),
|
||||
parseIp(ip),
|
||||
eventsQueue.getJobs(['delayed']),
|
||||
]);
|
||||
const [geo, eventsJobs] = Promise.all([
|
||||
parseIp(ip),
|
||||
eventsQueue.getJobs(['delayed']),
|
||||
]);
|
||||
const payload: IServiceCreateEventPayload = {
|
||||
name: body.name,
|
||||
profileId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { Profile } from '@prisma/client';
|
||||
|
||||
export type IDBEvent = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -9,14 +11,6 @@ export type IDBEvent = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type IDBProfile = {
|
||||
id: string;
|
||||
external_id?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
properties: Record<string, string>;
|
||||
project_id: String;
|
||||
created_at: string;
|
||||
export type IDBProfile = Omit<Profile, 'properties'> & {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { omit } from 'ramda';
|
||||
|
||||
import { toDots } from '@mixan/common';
|
||||
import { redisPub } from '@mixan/redis';
|
||||
import { randomSplitName, toDots } from '@mixan/common';
|
||||
import { redis, redisPub } from '@mixan/redis';
|
||||
|
||||
import { ch, chQuery, formatClickhouseDate } from '../clickhouse-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export interface IClickhouseEvent {
|
||||
name: string;
|
||||
@@ -12,6 +13,7 @@ export interface IClickhouseEvent {
|
||||
path: string;
|
||||
referrer: string;
|
||||
referrer_name: string;
|
||||
referrer_type: string;
|
||||
duration: number;
|
||||
properties: Record<string, string | number | boolean>;
|
||||
created_at: string;
|
||||
@@ -50,6 +52,7 @@ export function transformEvent(
|
||||
path: event.path,
|
||||
referrer: event.referrer,
|
||||
referrerName: event.referrer_name,
|
||||
referrerType: event.referrer_type,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ export interface IServiceCreateEventPayload {
|
||||
path: string;
|
||||
referrer: string | undefined;
|
||||
referrerName: string | undefined;
|
||||
referrerType: string | undefined;
|
||||
}
|
||||
|
||||
export function getEvents(sql: string) {
|
||||
@@ -88,6 +92,41 @@ export function getEvents(sql: string) {
|
||||
export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
console.log(`create event ${payload.name} for ${payload.profileId}`);
|
||||
|
||||
if (payload.name === 'session_start') {
|
||||
const profile = await db.profile.findUnique({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
const { firstName, lastName } = randomSplitName();
|
||||
await db.profile.create({
|
||||
data: {
|
||||
id: payload.profileId,
|
||||
project_id: payload.projectId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
properties: {
|
||||
country: payload.country ?? '',
|
||||
city: payload.city ?? '',
|
||||
region: payload.region ?? '',
|
||||
os: payload.os ?? '',
|
||||
os_version: payload.osVersion ?? '',
|
||||
browser: payload.browser ?? '',
|
||||
browser_version: payload.browserVersion ?? '',
|
||||
device: payload.device ?? '',
|
||||
brand: payload.brand ?? '',
|
||||
model: payload.model ?? '',
|
||||
referrer: payload.referrer ?? '',
|
||||
referrer_name: payload.referrerName ?? '',
|
||||
referrer_type: payload.referrerType ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.properties.hash === '') {
|
||||
delete payload.properties.hash;
|
||||
}
|
||||
@@ -112,6 +151,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
duration: payload.duration,
|
||||
referrer: payload.referrer ?? '',
|
||||
referrer_name: payload.referrerName ?? '',
|
||||
referrer_type: payload.referrerType ?? '',
|
||||
};
|
||||
|
||||
const res = await ch.insert({
|
||||
@@ -121,6 +161,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
});
|
||||
|
||||
redisPub.publish('event', JSON.stringify(transformEvent(event)));
|
||||
redis.set(`live:event:${event.project_id}:${event.profile_id}`, '', 'EX', 10);
|
||||
|
||||
return {
|
||||
...res,
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
import type { NewMixanOptions } from '@mixan/sdk';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
import * as Application from 'expo-application';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Network from 'expo-network';
|
||||
|
||||
import type { MixanOptions } from '@mixan/sdk';
|
||||
import { Mixan } from '@mixan/sdk';
|
||||
|
||||
export class MixanNative extends Mixan {
|
||||
constructor(options: NewMixanOptions) {
|
||||
type MixanNativeOptions = MixanOptions & {
|
||||
ipUrl?: string;
|
||||
};
|
||||
|
||||
export class MixanNative extends Mixan<MixanNativeOptions> {
|
||||
constructor(options: MixanNativeOptions) {
|
||||
super(options);
|
||||
|
||||
this.api.headers['X-Forwarded-For'] = Network.getIpAddressAsync();
|
||||
this.api.headers['User-Agent'] = Constants.getWebViewUserAgentAsync();
|
||||
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
this.setProperties();
|
||||
}
|
||||
});
|
||||
|
||||
this.setProperties();
|
||||
}
|
||||
|
||||
init(properties?: Record<string, unknown>) {
|
||||
super.init({
|
||||
...(properties ?? {}),
|
||||
private async setProperties() {
|
||||
this.setGlobalProperties({
|
||||
version: Application.nativeApplicationVersion,
|
||||
buildNumber: Application.nativeBuildVersion,
|
||||
referrer:
|
||||
Platform.OS === 'android'
|
||||
? await Application.getInstallReferrerAsync()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
screenView(route: string, properties?: Record<string, unknown>): void {
|
||||
public screenView(route: string, properties?: Record<string, unknown>): void {
|
||||
super.event('screen_view', {
|
||||
...properties,
|
||||
route: route,
|
||||
path: route,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "^0.72.5",
|
||||
"expo-application": "~5.3.0",
|
||||
"expo-constants": "~14.4.2",
|
||||
"expo-network": "~5.8.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
import config from '@mixan/tsconfig/tsup.config.json' assert {
|
||||
type: 'json'
|
||||
}
|
||||
import config from '@mixan/tsconfig/tsup.config.json' assert { type: 'json' };
|
||||
|
||||
export default defineConfig(config as any);
|
||||
export default defineConfig({
|
||||
...(config as any),
|
||||
minify: false,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ if (el) {
|
||||
window.openpanel = new Openpanel({
|
||||
url: el?.getAttribute('data-url'),
|
||||
clientId: el?.getAttribute('data-client-id'),
|
||||
clientSecret: el?.getAttribute('data-client-secret'),
|
||||
trackOutgoingLinks: !!el?.getAttribute('data-track-outgoing-links'),
|
||||
trackScreenViews: !!el?.getAttribute('data-track-screen-views'),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ type MixanWebOptions = MixanOptions & {
|
||||
};
|
||||
|
||||
export class MixanWeb extends Mixan<MixanWebOptions> {
|
||||
private lastPath = '';
|
||||
|
||||
constructor(options: MixanWebOptions) {
|
||||
super(options);
|
||||
|
||||
@@ -24,14 +26,6 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
|
||||
return typeof document === 'undefined';
|
||||
}
|
||||
|
||||
private getTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public trackOutgoingLinks() {
|
||||
if (this.isServer()) {
|
||||
return;
|
||||
@@ -90,9 +84,16 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = window.location.href;
|
||||
|
||||
if (this.lastPath === path) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPath = path;
|
||||
super.event('screen_view', {
|
||||
...(properties ?? {}),
|
||||
path: window.location.href,
|
||||
path,
|
||||
title: document.title,
|
||||
referrer: document.referrer,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"module": "index.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsup",
|
||||
"build-for-openpanel": "pnpm build && cp dist/cdn.global.js ../../apps/public/public/op.js",
|
||||
"build-for-openpanel": "pnpm build && cp dist/cdn.global.js ../../apps/public/public/op.js && cp dist/cdn.global.js ../../apps/test/public/op.js",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
import config from '@mixan/tsconfig/tsup.config.json' assert {
|
||||
type: 'json'
|
||||
}
|
||||
import config from '@mixan/tsconfig/tsup.config.json' assert { type: 'json' };
|
||||
|
||||
export default defineConfig(config as any);
|
||||
export default defineConfig({
|
||||
...(config as any),
|
||||
entry: ['index.ts', 'cdn.ts'],
|
||||
format: ['cjs', 'esm', 'iife'],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { PostEventPayload } from '@mixan/types';
|
||||
import type {
|
||||
DecrementProfilePayload,
|
||||
IncrementProfilePayload,
|
||||
PostEventPayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@mixan/types';
|
||||
|
||||
export interface MixanOptions {
|
||||
url: string;
|
||||
@@ -15,80 +20,94 @@ export interface MixanState {
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createApi(_url: string, clientId: string, clientSecret?: string) {
|
||||
return function post<ReqBody, ResBody>(
|
||||
path: string,
|
||||
data: ReqBody,
|
||||
options?: RequestInit
|
||||
): Promise<ResBody | null> {
|
||||
const url = `${_url}${path}`;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const headers: Record<string, string> = {
|
||||
'mixan-client-id': clientId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (clientSecret) {
|
||||
headers['mixan-client-secret'] = clientSecret;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const wrappedFetch = (attempt: number) => {
|
||||
clearTimeout(timer);
|
||||
fetch(url, {
|
||||
headers,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data ?? {}),
|
||||
keepalive: true,
|
||||
...(options ?? {}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status !== 200 && res.status !== 202) {
|
||||
return retry(attempt, resolve);
|
||||
}
|
||||
function awaitProperties(
|
||||
properties: Record<string, string | Promise<string | null>>
|
||||
): Promise<Record<string, string>> {
|
||||
return Promise.all(
|
||||
Object.entries(properties).map(async ([key, value]) => {
|
||||
return [key, (await value) ?? ''];
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
const response = await res.text();
|
||||
|
||||
if (!response) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
resolve(response as ResBody);
|
||||
function createApi(_url: string) {
|
||||
const headers: Record<string, string | Promise<string | null>> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
return {
|
||||
headers,
|
||||
async fetch<ReqBody, ResBody>(
|
||||
path: string,
|
||||
data: ReqBody,
|
||||
options?: RequestInit
|
||||
): Promise<ResBody | null> {
|
||||
const url = `${_url}${path}`;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const h = await awaitProperties(headers);
|
||||
return new Promise((resolve) => {
|
||||
const wrappedFetch = (attempt: number) => {
|
||||
clearTimeout(timer);
|
||||
fetch(url, {
|
||||
headers: h,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data ?? {}),
|
||||
keepalive: true,
|
||||
...(options ?? {}),
|
||||
})
|
||||
.catch(() => {
|
||||
return retry(attempt, resolve);
|
||||
});
|
||||
};
|
||||
.then(async (res) => {
|
||||
if (res.status !== 200 && res.status !== 202) {
|
||||
return retry(attempt, resolve);
|
||||
}
|
||||
|
||||
function retry(
|
||||
attempt: number,
|
||||
resolve: (value: ResBody | null) => void
|
||||
) {
|
||||
if (attempt > 1) {
|
||||
return resolve(null);
|
||||
const response = await res.text();
|
||||
|
||||
if (!response) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
resolve(response as ResBody);
|
||||
})
|
||||
.catch(() => {
|
||||
return retry(attempt, resolve);
|
||||
});
|
||||
};
|
||||
|
||||
function retry(
|
||||
attempt: number,
|
||||
resolve: (value: ResBody | null) => void
|
||||
) {
|
||||
if (attempt > 1) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
wrappedFetch(attempt + 1);
|
||||
},
|
||||
Math.pow(2, attempt) * 500
|
||||
);
|
||||
}
|
||||
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
wrappedFetch(attempt + 1);
|
||||
},
|
||||
Math.pow(2, attempt) * 500
|
||||
);
|
||||
}
|
||||
|
||||
wrappedFetch(0);
|
||||
});
|
||||
wrappedFetch(0);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class Mixan<Options extends MixanOptions = MixanOptions> {
|
||||
public options: Options;
|
||||
private api: ReturnType<typeof createApi>;
|
||||
public api: ReturnType<typeof createApi>;
|
||||
private state: MixanState = {
|
||||
properties: {},
|
||||
};
|
||||
|
||||
constructor(options: Options) {
|
||||
this.options = options;
|
||||
this.api = createApi(options.url, options.clientId, options.clientSecret);
|
||||
this.api = createApi(options.url);
|
||||
this.api.headers['mixan-client-id'] = options.clientId;
|
||||
if (this.options.clientSecret) {
|
||||
this.api.headers['mixan-client-secret'] = this.options.clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Public
|
||||
@@ -97,61 +116,49 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
|
||||
this.state.properties = properties ?? {};
|
||||
}
|
||||
|
||||
// public setUser(payload: Omit<BatchUpdateProfilePayload, 'profileId'>) {
|
||||
// this.batcher.add({
|
||||
// type: 'update_profile',
|
||||
// payload: {
|
||||
// ...payload,
|
||||
// properties: payload.properties ?? {},
|
||||
// profileId: this.state.profileId,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// public increment(name: string, value: number) {
|
||||
// this.batcher.add({
|
||||
// type: 'increment',
|
||||
// payload: {
|
||||
// name,
|
||||
// value,
|
||||
// profileId: this.state.profileId,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// public decrement(name: string, value: number) {
|
||||
// this.batcher.add({
|
||||
// type: 'decrement',
|
||||
// payload: {
|
||||
// name,
|
||||
// value,
|
||||
// profileId: this.state.profileId,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
private getProfileId() {
|
||||
if (this.state.profileId) {
|
||||
return this.state.profileId;
|
||||
} else if (this.options.getProfileId) {
|
||||
this.state.profileId = this.options.getProfileId() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async event(name: string, properties?: Record<string, unknown>) {
|
||||
const profileId = await this.api<PostEventPayload, string>('/event', {
|
||||
name,
|
||||
public setUser(payload: Omit<UpdateProfilePayload, 'profileId'>) {
|
||||
this.api.fetch<UpdateProfilePayload, string>('/profile', {
|
||||
profileId: this.getProfileId(),
|
||||
...payload,
|
||||
properties: {
|
||||
...this.state.properties,
|
||||
...(properties ?? {}),
|
||||
...payload.properties,
|
||||
},
|
||||
timestamp: this.timestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
public increment(property: string, value: number) {
|
||||
this.api.fetch<IncrementProfilePayload, string>('/profile/increment', {
|
||||
property,
|
||||
value,
|
||||
profileId: this.getProfileId(),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.options.setProfileId && profileId) {
|
||||
this.options.setProfileId(profileId);
|
||||
}
|
||||
public decrement(property: string, value: number) {
|
||||
this.api.fetch<DecrementProfilePayload, string>('/profile/decrement', {
|
||||
property,
|
||||
value,
|
||||
profileId: this.getProfileId(),
|
||||
});
|
||||
}
|
||||
|
||||
public event(name: string, properties?: Record<string, unknown>) {
|
||||
this.api
|
||||
.fetch<PostEventPayload, string>('/event', {
|
||||
name,
|
||||
properties: {
|
||||
...this.state.properties,
|
||||
...(properties ?? {}),
|
||||
},
|
||||
timestamp: this.timestamp(),
|
||||
profileId: this.getProfileId(),
|
||||
})
|
||||
.then((profileId) => {
|
||||
if (this.options.setProfileId && profileId) {
|
||||
this.options.setProfileId(profileId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setGlobalProperties(properties: Record<string, unknown>) {
|
||||
@@ -168,21 +175,17 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
|
||||
}
|
||||
}
|
||||
|
||||
public setUserProperty(name: string, value: unknown, update = true) {
|
||||
// this.batcher.add({
|
||||
// type: 'set_profile_property',
|
||||
// payload: {
|
||||
// name,
|
||||
// value,
|
||||
// update,
|
||||
// profileId: this.state.profileId,
|
||||
// },
|
||||
// });
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
private timestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
private getProfileId() {
|
||||
if (this.state.profileId) {
|
||||
return this.state.profileId;
|
||||
} else if (this.options.getProfileId) {
|
||||
this.state.profileId = this.options.getProfileId() || undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,5 +138,31 @@ export interface PostEventPayload {
|
||||
name: string;
|
||||
timestamp: string;
|
||||
profileId?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
properties?: Record<string, unknown> & {
|
||||
title?: string | undefined;
|
||||
referrer?: string | undefined;
|
||||
path?: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
profileId?: string;
|
||||
id?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
properties?: MixanJson;
|
||||
}
|
||||
|
||||
export interface IncrementProfilePayload {
|
||||
profileId?: string;
|
||||
property: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DecrementProfilePayload {
|
||||
profileId?: string;
|
||||
property: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
5565
pnpm-lock.yaml
generated
5565
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,11 @@ function main() {
|
||||
};
|
||||
|
||||
try {
|
||||
savePackageJson(workspacePath('./packages/types/package.json'), {
|
||||
...typesPkg,
|
||||
...properties,
|
||||
});
|
||||
|
||||
for (const name of sdkPackages) {
|
||||
const pkgJson = require(workspacePath(`./packages/${name}/package.json`));
|
||||
savePackageJson(workspacePath(`./packages/${name}/package.json`), {
|
||||
@@ -67,11 +72,6 @@ function main() {
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
savePackageJson(workspacePath('./packages/types/package.json'), {
|
||||
...typesPkg,
|
||||
...properties,
|
||||
});
|
||||
} catch (error) {
|
||||
exit('Update JSON files', error);
|
||||
}
|
||||
@@ -79,14 +79,15 @@ function main() {
|
||||
console.log('✅ Update JSON files');
|
||||
|
||||
try {
|
||||
execSync('pnpm build', {
|
||||
cwd: workspacePath(`./packages/types`),
|
||||
});
|
||||
|
||||
for (const name of sdkPackages) {
|
||||
execSync('pnpm build', {
|
||||
cwd: workspacePath(`./packages/${name}`),
|
||||
});
|
||||
}
|
||||
execSync('pnpm build', {
|
||||
cwd: workspacePath(`./packages/types`),
|
||||
});
|
||||
} catch (error) {
|
||||
exit('Failed build packages', error);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"entry": ["index.ts", "cdn.ts"],
|
||||
"format": ["cjs", "esm", "iife"],
|
||||
"entry": ["index.ts"],
|
||||
"format": ["cjs", "esm"],
|
||||
"dts": true,
|
||||
"splitting": false,
|
||||
"sourcemap": false,
|
||||
"sourcemap": true,
|
||||
"clean": true,
|
||||
"minify": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user