- {formatTimezoneDate(createdAt, 'h:mm:ss aaa')}
+ {formatTimezoneDate(createdAt, 'pp')}
{eventName ? : }
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx
index 3ce78d486..fc69494f8 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx
@@ -18,7 +18,8 @@ export default function SessionInfo({ data }) {
{data?.id}
-
+
{formatMessage(labels.distinctId)}
+
{data?.distinctId}
{formatMessage(labels.lastSeen)}
{formatTimezoneDate(data?.lastAt, 'PPPPpp')}
@@ -36,7 +37,7 @@ export default function SessionInfo({ data }) {
- {getRegionName(data?.subdivision1)}
+ {getRegionName(data?.region)}
{formatMessage(labels.city)}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 7ae22e2bd..bfac55489 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -26,7 +26,7 @@ export async function POST(request: Request) {
const user = await getUserByUsername(username, { includePassword: true });
if (!user || !checkPassword(password, user.password)) {
- return unauthorized();
+ return unauthorized('message.incorrect-username-password');
}
const { id, role, createdAt } = user;
diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts
index 4d98b5543..5f8543a55 100644
--- a/src/app/api/auth/verify/route.ts
+++ b/src/app/api/auth/verify/route.ts
@@ -1,7 +1,7 @@
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
-export async function GET(request: Request) {
+export async function POST(request: Request) {
const { auth, error } = await parseRequest(request);
if (error) {
diff --git a/src/app/api/batch/route.ts b/src/app/api/batch/route.ts
new file mode 100644
index 000000000..87e04110d
--- /dev/null
+++ b/src/app/api/batch/route.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import * as send from '@/app/api/send/route';
+import { parseRequest } from '@/lib/request';
+import { json, serverError } from '@/lib/response';
+
+const schema = z.array(z.object({}).passthrough());
+
+export async function POST(request: Request) {
+ try {
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const errors = [];
+
+ let index = 0;
+ for (const data of body) {
+ const newRequest = new Request(request, { body: JSON.stringify(data) });
+ const response = await send.POST(newRequest);
+
+ if (!response.ok) {
+ errors.push({ index, response: await response.json() });
+ }
+
+ index++;
+ }
+
+ return json({
+ size: body.length,
+ processed: body.length - errors.length,
+ errors: errors.length,
+ details: errors,
+ });
+ } catch (e) {
+ return serverError(e);
+ }
+}
diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts
new file mode 100644
index 000000000..a1f7992d0
--- /dev/null
+++ b/src/app/api/reports/attribution/route.ts
@@ -0,0 +1,50 @@
+import { canViewWebsite } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportParms } from '@/lib/schema';
+import { getAttribution } from '@/queries/sql/reports/getAttribution';
+import { z } from 'zod';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ model: z.string().regex(/firstClick|lastClick/i),
+ steps: z
+ .array(
+ z.object({
+ type: z.string(),
+ value: z.string(),
+ }),
+ )
+ .min(1),
+ currency: z.string().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ model,
+ steps,
+ currency,
+ dateRange: { startDate, endDate },
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getAttribution(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ model: model,
+ steps,
+ currency,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts
index a1bc62901..19ad98fa7 100644
--- a/src/app/api/reports/journey/route.ts
+++ b/src/app/api/reports/journey/route.ts
@@ -9,8 +9,8 @@ export async function POST(request: Request) {
const schema = z.object({
...reportParms,
steps: z.coerce.number().min(3).max(7),
- startStep: z.string(),
- endStep: z.string(),
+ startStep: z.string().optional(),
+ endStep: z.string().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts
index 933ef78e2..60d6f7af0 100644
--- a/src/app/api/send/route.ts
+++ b/src/app/api/send/route.ts
@@ -1,41 +1,40 @@
import { z } from 'zod';
import { isbot } from 'isbot';
-import { createToken, parseToken } from '@/lib/jwt';
+import { startOfHour, startOfMonth } from 'date-fns';
import clickhouse from '@/lib/clickhouse';
import { parseRequest } from '@/lib/request';
import { badRequest, json, forbidden, serverError } from '@/lib/response';
import { fetchSession, fetchWebsite } from '@/lib/load';
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
-import { secret, uuid, visitSalt } from '@/lib/crypto';
-import { COLLECTION_TYPE, DOMAIN_REGEX } from '@/lib/constants';
+import { createToken, parseToken } from '@/lib/jwt';
+import { secret, uuid, hash } from '@/lib/crypto';
+import { COLLECTION_TYPE } from '@/lib/constants';
+import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
+import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
import { createSession, saveEvent, saveSessionData } from '@/queries';
-import { urlOrPathParam } from '@/lib/schema';
const schema = z.object({
type: z.enum(['event', 'identify']),
payload: z.object({
website: z.string().uuid(),
- data: z.object({}).passthrough().optional(),
- hostname: z.string().regex(DOMAIN_REGEX).max(100).optional(),
+ data: anyObjectParam.optional(),
+ hostname: z.string().max(100).optional(),
language: z.string().max(35).optional(),
referrer: urlOrPathParam.optional(),
screen: z.string().max(11).optional(),
title: z.string().optional(),
- url: urlOrPathParam,
+ url: urlOrPathParam.optional(),
name: z.string().max(50).optional(),
tag: z.string().max(50).optional(),
ip: z.string().ip().optional(),
userAgent: z.string().optional(),
+ timestamp: z.coerce.number().int().optional(),
+ id: z.string().optional(),
}),
});
export async function POST(request: Request) {
try {
- // Bot check
- if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
- return json({ beep: 'boop' });
- }
-
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) {
@@ -55,6 +54,8 @@ export async function POST(request: Request) {
data,
title,
tag,
+ timestamp,
+ id,
} = payload;
// Cache check
@@ -79,15 +80,28 @@ export async function POST(request: Request) {
}
// Client info
- const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
- await getClientInfo(request, payload);
+ const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo(
+ request,
+ payload,
+ );
+
+ // Bot check
+ if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
+ return json({ beep: 'boop' });
+ }
// IP block
if (hasBlockedIp(ip)) {
return forbidden();
}
- const sessionId = uuid(websiteId, ip, userAgent);
+ const createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
+ const now = Math.floor(new Date().getTime() / 1000);
+
+ const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
+ const visitSalt = hash(startOfHour(createdAt).toUTCString());
+
+ const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
// Find session
if (!clickhouse.enabled && !cache?.sessionId) {
@@ -99,16 +113,15 @@ export async function POST(request: Request) {
await createSession({
id: sessionId,
websiteId,
- hostname,
browser,
os,
device,
screen,
language,
country,
- subdivision1,
- subdivision2,
+ region,
city,
+ distinctId: id,
});
} catch (e: any) {
if (!e.message.toLowerCase().includes('unique constraint')) {
@@ -119,13 +132,12 @@ export async function POST(request: Request) {
}
// Visit info
- const now = Math.floor(new Date().getTime() / 1000);
- let visitId = cache?.visitId || uuid(sessionId, visitSalt());
+ let visitId = cache?.visitId || uuid(sessionId, visitSalt);
let iat = cache?.iat || now;
// Expire visit after 30 minutes
- if (now - iat > 1800) {
- visitId = uuid(sessionId, visitSalt());
+ if (!timestamp && now - iat > 1800) {
+ visitId = uuid(sessionId, visitSalt);
iat = now;
}
@@ -133,18 +145,33 @@ export async function POST(request: Request) {
const base = hostname ? `https://${hostname}` : 'https://localhost';
const currentUrl = new URL(url, base);
- let urlPath = currentUrl.pathname;
+ let urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname;
const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www./, '');
- if (process.env.REMOVE_TRAILING_SLASH) {
- urlPath = urlPath.replace(/(.+)\/$/, '$1');
- }
-
let referrerPath: string;
let referrerQuery: string;
let referrerDomain: string;
+ // UTM Params
+ const utmSource = currentUrl.searchParams.get('utm_source');
+ const utmMedium = currentUrl.searchParams.get('utm_medium');
+ const utmCampaign = currentUrl.searchParams.get('utm_campaign');
+ const utmContent = currentUrl.searchParams.get('utm_content');
+ const utmTerm = currentUrl.searchParams.get('utm_term');
+
+ // Click IDs
+ const gclid = currentUrl.searchParams.get('gclid');
+ const fbclid = currentUrl.searchParams.get('fbclid');
+ const msclkid = currentUrl.searchParams.get('msclkid');
+ const ttclid = currentUrl.searchParams.get('ttclid');
+ const lifatid = currentUrl.searchParams.get('li_fat_id');
+ const twclid = currentUrl.searchParams.get('twclid');
+
+ if (process.env.REMOVE_TRAILING_SLASH) {
+ urlPath = urlPath.replace(/(.+)\/$/, '$1');
+ }
+
if (referrer) {
const referrerUrl = new URL(referrer, base);
@@ -160,43 +187,65 @@ export async function POST(request: Request) {
websiteId,
sessionId,
visitId,
- urlPath,
+ createdAt,
+
+ // Page
+ pageTitle: safeDecodeURIComponent(title),
+ hostname: hostname || urlDomain,
+ urlPath: safeDecodeURI(urlPath),
urlQuery,
- referrerPath,
+ referrerPath: safeDecodeURI(referrerPath),
referrerQuery,
referrerDomain,
- pageTitle: title,
- eventName: name,
- eventData: data,
- hostname: hostname || urlDomain,
+
+ // Session
+ distinctId: id,
browser,
os,
device,
screen,
language,
country,
- subdivision1,
- subdivision2,
+ region,
city,
+
+ // Events
+ eventName: name,
+ eventData: data,
tag,
+
+ // UTM
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ utmContent,
+ utmTerm,
+
+ // Click IDs
+ gclid,
+ fbclid,
+ msclkid,
+ ttclid,
+ lifatid,
+ twclid,
});
}
if (type === COLLECTION_TYPE.identify) {
- if (!data) {
- return badRequest('Data required.');
+ if (data) {
+ await saveSessionData({
+ websiteId,
+ sessionId,
+ sessionData: data,
+ distinctId: id,
+ createdAt,
+ });
}
-
- await saveSessionData({
- websiteId,
- sessionId,
- sessionData: data,
- });
}
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
- return json({ cache: token });
+ return json({ cache: token, sessionId, visitId });
} catch (e) {
return serverError(e);
}
diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts
index f7f4b3316..194e7bbb1 100644
--- a/src/app/api/teams/[teamId]/route.ts
+++ b/src/app/api/teams/[teamId]/route.ts
@@ -28,8 +28,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
const schema = z.object({
- name: z.string().max(50),
- accessCode: z.string().max(50),
+ name: z.string().max(50).optional(),
+ accessCode: z.string().max(50).optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts
index abb3331d0..d011783c4 100644
--- a/src/app/api/users/[userId]/route.ts
+++ b/src/app/api/users/[userId]/route.ts
@@ -1,9 +1,8 @@
-import { z } from 'zod';
-import { canUpdateUser, canViewUser, canDeleteUser } from '@/lib/auth';
-import { getUser, getUserByUsername, updateUser, deleteUser } from '@/queries';
-import { json, unauthorized, badRequest, ok } from '@/lib/response';
-import { hashPassword } from '@/lib/auth';
+import { canDeleteUser, canUpdateUser, canViewUser, hashPassword } from '@/lib/auth';
import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, unauthorized } from '@/lib/response';
+import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries';
+import { z } from 'zod';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const { auth, error } = await parseRequest(request);
@@ -26,8 +25,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
username: z.string().max(255),
- password: z.string().max(255),
- role: z.string().regex(/admin|user|view-only/i),
+ password: z.string().max(255).optional(),
+ role: z
+ .string()
+ .regex(/admin|user|view-only/i)
+ .optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index f6b32fe7e..c5896f892 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -8,6 +8,7 @@ import { createUser, getUserByUsername } from '@/queries';
export async function POST(request: Request) {
const schema = z.object({
+ id: z.string().uuid().optional(),
username: z.string().max(255),
password: z.string(),
role: z.string().regex(/admin|user|view-only/i),
@@ -23,7 +24,7 @@ export async function POST(request: Request) {
return unauthorized();
}
- const { username, password, role } = body;
+ const { id, username, password, role } = body;
const existingUser = await getUserByUsername(username, { showDeleted: true });
@@ -32,7 +33,7 @@ export async function POST(request: Request) {
}
const user = await createUser({
- id: uuid(),
+ id: id || uuid(),
username,
password: hashPassword(password),
role: role ?? ROLES.user,
diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts
deleted file mode 100644
index 275a41184..000000000
--- a/src/app/api/version/route.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { json } from '@/lib/response';
-import { CURRENT_VERSION } from '@/lib/constants';
-
-export async function GET() {
- return json({ version: CURRENT_VERSION });
-}
diff --git a/src/app/api/websites/[websiteId]/metrics/route.ts b/src/app/api/websites/[websiteId]/metrics/route.ts
index 1c3c804c7..854339041 100644
--- a/src/app/api/websites/[websiteId]/metrics/route.ts
+++ b/src/app/api/websites/[websiteId]/metrics/route.ts
@@ -136,7 +136,15 @@ function getChannels(data: { domain: string; query: string; visitors: number }[]
const prefix = /utm_medium=(.*cp.*|ppc|retargeting|paid.*)/.test(query) ? 'paid' : 'organic';
- if (SEARCH_DOMAINS.some(match(domain)) || /utm_medium=organic/.test(query)) {
+ if (PAID_AD_PARAMS.some(match(query))) {
+ channels.paidAds += Number(visitors);
+ } else if (/utm_medium=(referral|app|link)/.test(query)) {
+ channels.referral += Number(visitors);
+ } else if (/utm_medium=affiliate/.test(query)) {
+ channels.affiliate += Number(visitors);
+ } else if (/utm_(source|medium)=sms/.test(query)) {
+ channels.sms += Number(visitors);
+ } else if (SEARCH_DOMAINS.some(match(domain)) || /utm_medium=organic/.test(query)) {
channels[`${prefix}Search`] += Number(visitors);
} else if (
SOCIAL_DOMAINS.some(match(domain)) ||
@@ -152,14 +160,6 @@ function getChannels(data: { domain: string; query: string; visitors: number }[]
channels[`${prefix}Shopping`] += Number(visitors);
} else if (VIDEO_DOMAINS.some(match(domain)) || /utm_medium=(.*video.*)/.test(query)) {
channels[`${prefix}Video`] += Number(visitors);
- } else if (PAID_AD_PARAMS.some(match(query))) {
- channels.paidAds += Number(visitors);
- } else if (/utm_medium=(referral|app|link)/.test(query)) {
- channels.referral += Number(visitors);
- } else if (/utm_medium=affiliate/.test(query)) {
- channels.affiliate += Number(visitors);
- } else if (/utm_(source|medium)=sms/.test(query)) {
- channels.sms += Number(visitors);
}
}
diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts
index f4ea327b9..346e58569 100644
--- a/src/app/api/websites/[websiteId]/route.ts
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -33,7 +33,7 @@ export async function POST(
const schema = z.object({
name: z.string(),
domain: z.string(),
- shareId: z.string().regex(SHARE_ID_REGEX).nullable(),
+ shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f88d8169c..ebe313e62 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -23,7 +23,7 @@ export default function ({ children }) {
-
+
{children}
diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx
index 1da3106c6..a808c622d 100644
--- a/src/app/login/LoginForm.tsx
+++ b/src/app/login/LoginForm.tsx
@@ -16,7 +16,7 @@ import Logo from '@/assets/logo.svg';
import styles from './LoginForm.module.css';
export function LoginForm() {
- const { formatMessage, labels } = useMessages();
+ const { formatMessage, labels, getMessage } = useMessages();
const router = useRouter();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
@@ -40,7 +40,7 @@ export function LoginForm() {
umami
-