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',
|
||||
|
||||
Reference in New Issue
Block a user