aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/api
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/admin/teams/route.ts58
-rw-r--r--src/app/api/admin/users/route.ts46
-rw-r--r--src/app/api/admin/websites/route.ts58
-rw-r--r--src/app/api/auth/login/route.ts48
-rw-r--r--src/app/api/auth/logout/route.ts12
-rw-r--r--src/app/api/auth/sso/route.ts18
-rw-r--r--src/app/api/auth/verify/route.ts15
-rw-r--r--src/app/api/batch/route.ts58
-rw-r--r--src/app/api/config/route.ts21
-rw-r--r--src/app/api/heartbeat/route.ts3
-rw-r--r--src/app/api/links/[linkId]/route.ts77
-rw-r--r--src/app/api/links/route.ts64
-rw-r--r--src/app/api/me/password/route.ts33
-rw-r--r--src/app/api/me/route.ts12
-rw-r--r--src/app/api/me/teams/route.ts23
-rw-r--r--src/app/api/me/websites/route.ts26
-rw-r--r--src/app/api/pixels/[pixelId]/route.ts76
-rw-r--r--src/app/api/pixels/route.ts62
-rw-r--r--src/app/api/realtime/[websiteId]/route.ts36
-rw-r--r--src/app/api/reports/[reportId]/route.ts80
-rw-r--r--src/app/api/reports/attribution/route.ts26
-rw-r--r--src/app/api/reports/breakdown/route.ts26
-rw-r--r--src/app/api/reports/funnel/route.ts26
-rw-r--r--src/app/api/reports/goal/route.ts26
-rw-r--r--src/app/api/reports/journey/route.ts25
-rw-r--r--src/app/api/reports/retention/route.ts26
-rw-r--r--src/app/api/reports/revenue/route.ts26
-rw-r--r--src/app/api/reports/route.ts73
-rw-r--r--src/app/api/reports/utm/route.ts37
-rw-r--r--src/app/api/scripts/telemetry/route.ts28
-rw-r--r--src/app/api/send/route.ts284
-rw-r--r--src/app/api/share/[shareId]/route.ts19
-rw-r--r--src/app/api/teams/[teamId]/links/route.ts29
-rw-r--r--src/app/api/teams/[teamId]/pixels/route.ts29
-rw-r--r--src/app/api/teams/[teamId]/route.ts71
-rw-r--r--src/app/api/teams/[teamId]/users/[userId]/route.ts85
-rw-r--r--src/app/api/teams/[teamId]/users/route.ts83
-rw-r--r--src/app/api/teams/[teamId]/websites/route.ts29
-rw-r--r--src/app/api/teams/join/route.ts39
-rw-r--r--src/app/api/teams/route.ts55
-rw-r--r--src/app/api/users/[userId]/route.ts102
-rw-r--r--src/app/api/users/[userId]/teams/route.ts27
-rw-r--r--src/app/api/users/[userId]/websites/route.ts33
-rw-r--r--src/app/api/users/route.ts44
-rw-r--r--src/app/api/websites/[websiteId]/active/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/daterange/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/event-data/events/route.ts37
-rw-r--r--src/app/api/websites/[websiteId]/event-data/fields/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/event-data/properties/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/event-data/stats/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/event-data/values/route.ts41
-rw-r--r--src/app/api/websites/[websiteId]/events/route.ts37
-rw-r--r--src/app/api/websites/[websiteId]/events/series/route.ts37
-rw-r--r--src/app/api/websites/[websiteId]/export/route.ts64
-rw-r--r--src/app/api/websites/[websiteId]/metrics/expanded/route.ts66
-rw-r--r--src/app/api/websites/[websiteId]/metrics/route.ts66
-rw-r--r--src/app/api/websites/[websiteId]/pageviews/route.ts72
-rw-r--r--src/app/api/websites/[websiteId]/reports/route.ts46
-rw-r--r--src/app/api/websites/[websiteId]/reset/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/route.ts84
-rw-r--r--src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts92
-rw-r--r--src/app/api/websites/[websiteId]/segments/route.ts70
-rw-r--r--src/app/api/websites/[websiteId]/session-data/properties/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/session-data/values/route.ts40
-rw-r--r--src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts33
-rw-r--r--src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/sessions/route.ts36
-rw-r--r--src/app/api/websites/[websiteId]/sessions/stats/route.ts42
-rw-r--r--src/app/api/websites/[websiteId]/sessions/weekly/route.ts36
-rw-r--r--src/app/api/websites/[websiteId]/stats/route.ts43
-rw-r--r--src/app/api/websites/[websiteId]/transfer/route.ts50
-rw-r--r--src/app/api/websites/[websiteId]/values/route.ts50
-rw-r--r--src/app/api/websites/route.ts86
75 files changed, 3492 insertions, 0 deletions
diff --git a/src/app/api/admin/teams/route.ts b/src/app/api/admin/teams/route.ts
new file mode 100644
index 0000000..ceb16ab
--- /dev/null
+++ b/src/app/api/admin/teams/route.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewAllTeams } from '@/permissions';
+import { getTeams } from '@/queries/prisma/team';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewAllTeams(auth))) {
+ return unauthorized();
+ }
+
+ const teams = await getTeams(
+ {
+ include: {
+ members: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ },
+ _count: {
+ select: {
+ websites: {
+ where: { deletedAt: null },
+ },
+ members: {
+ where: {
+ user: { deletedAt: null },
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ query,
+ );
+
+ return json(teams);
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..2e52261
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewUsers } from '@/permissions';
+import { getUsers } from '@/queries/prisma/user';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewUsers(auth))) {
+ return unauthorized();
+ }
+
+ const users = await getUsers(
+ {
+ include: {
+ _count: {
+ select: {
+ websites: {
+ where: { deletedAt: null },
+ },
+ },
+ },
+ },
+ omit: {
+ password: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ query,
+ );
+
+ return json(users);
+}
diff --git a/src/app/api/admin/websites/route.ts b/src/app/api/admin/websites/route.ts
new file mode 100644
index 0000000..09b2ef9
--- /dev/null
+++ b/src/app/api/admin/websites/route.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+import { ROLES } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewAllWebsites } from '@/permissions';
+import { getWebsites } from '@/queries/prisma/website';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewAllWebsites(auth))) {
+ return unauthorized();
+ }
+
+ const websites = await getWebsites(
+ {
+ include: {
+ user: {
+ where: {
+ deletedAt: null,
+ },
+ select: {
+ username: true,
+ id: true,
+ },
+ },
+ team: {
+ where: {
+ deletedAt: null,
+ },
+ include: {
+ members: {
+ where: {
+ role: ROLES.teamOwner,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ query,
+ );
+
+ return json(websites);
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..17ca2f7
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,48 @@
+import { z } from 'zod';
+import { saveAuth } from '@/lib/auth';
+import { ROLES } from '@/lib/constants';
+import { secret } from '@/lib/crypto';
+import { createSecureToken } from '@/lib/jwt';
+import { checkPassword } from '@/lib/password';
+import redis from '@/lib/redis';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { getAllUserTeams, getUserByUsername } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ username: z.string(),
+ password: z.string(),
+ });
+
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const { username, password } = body;
+
+ const user = await getUserByUsername(username, { includePassword: true });
+
+ if (!user || !checkPassword(password, user.password)) {
+ return unauthorized({ code: 'incorrect-username-password' });
+ }
+
+ const { id, role, createdAt } = user;
+
+ let token: string;
+
+ if (redis.enabled) {
+ token = await saveAuth({ userId: id, role });
+ } else {
+ token = createSecureToken({ userId: user.id, role }, secret());
+ }
+
+ const teams = await getAllUserTeams(id);
+
+ return json({
+ token,
+ user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams },
+ });
+}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..7bf0a81
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,12 @@
+import redis from '@/lib/redis';
+import { ok } from '@/lib/response';
+
+export async function POST(request: Request) {
+ if (redis.enabled) {
+ const token = request.headers.get('authorization')?.split(' ')?.[1];
+
+ await redis.client.del(token);
+ }
+
+ return ok();
+}
diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts
new file mode 100644
index 0000000..bba3dde
--- /dev/null
+++ b/src/app/api/auth/sso/route.ts
@@ -0,0 +1,18 @@
+import { saveAuth } from '@/lib/auth';
+import redis from '@/lib/redis';
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+
+export async function POST(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ if (redis.enabled) {
+ const token = await saveAuth({ userId: auth.user.id }, 86400);
+
+ return json({ user: auth.user, token });
+ }
+}
diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts
new file mode 100644
index 0000000..b308b7b
--- /dev/null
+++ b/src/app/api/auth/verify/route.ts
@@ -0,0 +1,15 @@
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+import { getAllUserTeams } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const teams = await getAllUserTeams(auth.user.id);
+
+ return json({ ...auth.user, teams });
+}
diff --git a/src/app/api/batch/route.ts b/src/app/api/batch/route.ts
new file mode 100644
index 0000000..46e8b3c
--- /dev/null
+++ b/src/app/api/batch/route.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+import * as send from '@/app/api/send/route';
+import { parseRequest } from '@/lib/request';
+import { json, serverError } from '@/lib/response';
+import { anyObjectParam } from '@/lib/schema';
+
+const schema = z.array(anyObjectParam);
+
+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;
+ let cache = null;
+ for (const data of body) {
+ // Recreate a fresh Request since `new Request(request)` will have the following error:
+ // > Cannot read private member #state from an object whose class did not declare it
+
+ // Copy headers we received, ensure JSON content type, and avoid conflicting content-length
+ const headers = new Headers(request.headers);
+ headers.set('content-type', 'application/json');
+ headers.delete('content-length');
+
+ const newRequest = new Request(request.url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(data),
+ });
+
+ const response = await send.POST(newRequest);
+ const responseJson = await response.json();
+
+ if (!response.ok) {
+ errors.push({ index, response: responseJson });
+ } else {
+ cache ??= responseJson.cache;
+ }
+
+ index++;
+ }
+
+ return json({
+ size: body.length,
+ processed: body.length - errors.length,
+ errors: errors.length,
+ details: errors,
+ cache,
+ });
+ } catch (e) {
+ return serverError(e);
+ }
+}
diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts
new file mode 100644
index 0000000..4e40caa
--- /dev/null
+++ b/src/app/api/config/route.ts
@@ -0,0 +1,21 @@
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+
+export async function GET(request: Request) {
+ const { error } = await parseRequest(request, null, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ return json({
+ cloudMode: !!process.env.CLOUD_MODE,
+ faviconUrl: process.env.FAVICON_URL,
+ linksUrl: process.env.LINKS_URL,
+ pixelsUrl: process.env.PIXELS_URL,
+ privateMode: !!process.env.PRIVATE_MODE,
+ telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
+ trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
+ updatesDisabled: !!process.env.DISABLE_UPDATES,
+ });
+}
diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts
new file mode 100644
index 0000000..9146308
--- /dev/null
+++ b/src/app/api/heartbeat/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+ return Response.json({ ok: true });
+}
diff --git a/src/app/api/links/[linkId]/route.ts b/src/app/api/links/[linkId]/route.ts
new file mode 100644
index 0000000..92f572c
--- /dev/null
+++ b/src/app/api/links/[linkId]/route.ts
@@ -0,0 +1,77 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { canDeleteLink, canUpdateLink, canViewLink } from '@/permissions';
+import { deleteLink, getLink, updateLink } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { linkId } = await params;
+
+ if (!(await canViewLink(auth, linkId))) {
+ return unauthorized();
+ }
+
+ const website = await getLink(linkId);
+
+ return json(website);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
+ const schema = z.object({
+ name: z.string().optional(),
+ url: z.string().optional(),
+ slug: z.string().min(8).optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { linkId } = await params;
+ const { name, url, slug } = body;
+
+ if (!(await canUpdateLink(auth, linkId))) {
+ return unauthorized();
+ }
+
+ try {
+ const result = await updateLink(linkId, { name, url, slug });
+
+ return Response.json(result);
+ } catch (e: any) {
+ if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
+ return badRequest({ message: 'That slug is already taken.' });
+ }
+
+ return serverError(e);
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ linkId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { linkId } = await params;
+
+ if (!(await canDeleteLink(auth, linkId))) {
+ return unauthorized();
+ }
+
+ await deleteLink(linkId);
+
+ return ok();
+}
diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts
new file mode 100644
index 0000000..a639888
--- /dev/null
+++ b/src/app/api/links/route.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
+import { createLink, getUserLinks } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const links = await getUserLinks(auth.user.id, filters);
+
+ return json(links);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(100),
+ url: z.string().max(500),
+ slug: z.string().max(100),
+ teamId: z.string().nullable().optional(),
+ id: z.uuid().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { id, name, url, slug, teamId } = body;
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ id: id ?? uuid(),
+ name,
+ url,
+ slug,
+ teamId,
+ };
+
+ if (!teamId) {
+ data.userId = auth.user.id;
+ }
+
+ const result = await createLink(data);
+
+ return json(result);
+}
diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts
new file mode 100644
index 0000000..24c7370
--- /dev/null
+++ b/src/app/api/me/password/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { checkPassword, hashPassword } from '@/lib/password';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json } from '@/lib/response';
+import { getUser, updateUser } from '@/queries/prisma/user';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ currentPassword: z.string(),
+ newPassword: z.string().min(8),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const userId = auth.user.id;
+ const { currentPassword, newPassword } = body;
+
+ const user = await getUser(userId, { includePassword: true });
+
+ if (!checkPassword(currentPassword, user.password)) {
+ return badRequest({ message: 'Current password is incorrect' });
+ }
+
+ const password = hashPassword(newPassword);
+
+ const updated = await updateUser(userId, { password });
+
+ return json(updated);
+}
diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts
new file mode 100644
index 0000000..59a3255
--- /dev/null
+++ b/src/app/api/me/route.ts
@@ -0,0 +1,12 @@
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+
+export async function GET(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ return json(auth);
+}
diff --git a/src/app/api/me/teams/route.ts b/src/app/api/me/teams/route.ts
new file mode 100644
index 0000000..555bf30
--- /dev/null
+++ b/src/app/api/me/teams/route.ts
@@ -0,0 +1,23 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { getUserTeams } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const teams = await getUserTeams(auth.user.id, filters);
+
+ return json(teams);
+}
diff --git a/src/app/api/me/websites/route.ts b/src/app/api/me/websites/route.ts
new file mode 100644
index 0000000..9ec39c7
--- /dev/null
+++ b/src/app/api/me/websites/route.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ includeTeams: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ if (query.includeTeams) {
+ return json(await getAllUserWebsitesIncludingTeamOwner(auth.user.id, filters));
+ }
+
+ return json(await getUserWebsites(auth.user.id, filters));
+}
diff --git a/src/app/api/pixels/[pixelId]/route.ts b/src/app/api/pixels/[pixelId]/route.ts
new file mode 100644
index 0000000..ecaf1fd
--- /dev/null
+++ b/src/app/api/pixels/[pixelId]/route.ts
@@ -0,0 +1,76 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { canDeletePixel, canUpdatePixel, canViewPixel } from '@/permissions';
+import { deletePixel, getPixel, updatePixel } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ pixelId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { pixelId } = await params;
+
+ if (!(await canViewPixel(auth, pixelId))) {
+ return unauthorized();
+ }
+
+ const pixel = await getPixel(pixelId);
+
+ return json(pixel);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ pixelId: string }> }) {
+ const schema = z.object({
+ name: z.string().optional(),
+ slug: z.string().min(8).optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { pixelId } = await params;
+ const { name, slug } = body;
+
+ if (!(await canUpdatePixel(auth, pixelId))) {
+ return unauthorized();
+ }
+
+ try {
+ const pixel = await updatePixel(pixelId, { name, slug });
+
+ return Response.json(pixel);
+ } catch (e: any) {
+ if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
+ return badRequest({ message: 'That slug is already taken.' });
+ }
+
+ return serverError(e);
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ pixelId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { pixelId } = await params;
+
+ if (!(await canDeletePixel(auth, pixelId))) {
+ return unauthorized();
+ }
+
+ await deletePixel(pixelId);
+
+ return ok();
+}
diff --git a/src/app/api/pixels/route.ts b/src/app/api/pixels/route.ts
new file mode 100644
index 0000000..8baae4f
--- /dev/null
+++ b/src/app/api/pixels/route.ts
@@ -0,0 +1,62 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
+import { createPixel, getUserPixels } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const links = await getUserPixels(auth.user.id, filters);
+
+ return json(links);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(100),
+ slug: z.string().max(100),
+ teamId: z.string().nullable().optional(),
+ id: z.uuid().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { id, name, slug, teamId } = body;
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ id: id ?? uuid(),
+ name,
+ slug,
+ teamId,
+ };
+
+ if (!teamId) {
+ data.userId = auth.user.id;
+ }
+
+ const result = await createPixel(data);
+
+ return json(result);
+}
diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts
new file mode 100644
index 0000000..32b7a16
--- /dev/null
+++ b/src/app/api/realtime/[websiteId]/route.ts
@@ -0,0 +1,36 @@
+import { startOfMinute, subMinutes } from 'date-fns';
+import { REALTIME_RANGE } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getRealtimeData } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, query, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(
+ {
+ ...query,
+ startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(),
+ endAt: Date.now(),
+ },
+ websiteId,
+ );
+
+ const data = await getRealtimeData(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts
new file mode 100644
index 0000000..1f22c62
--- /dev/null
+++ b/src/app/api/reports/[reportId]/route.ts
@@ -0,0 +1,80 @@
+import { parseRequest } from '@/lib/request';
+import { json, notFound, ok, unauthorized } from '@/lib/response';
+import { reportSchema } from '@/lib/schema';
+import { canDeleteWebsite, canUpdateWebsite, canViewReport } from '@/permissions';
+import { deleteReport, getReport, updateReport } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { reportId } = await params;
+
+ const report = await getReport(reportId);
+
+ if (!(await canViewReport(auth, report))) {
+ return unauthorized();
+ }
+
+ return json(report);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ reportId: string }> },
+) {
+ const { auth, body, error } = await parseRequest(request, reportSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { reportId } = await params;
+ const { websiteId, type, name, description, parameters } = body;
+
+ const report = await getReport(reportId);
+
+ if (!report) {
+ return notFound();
+ }
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await updateReport(reportId, {
+ websiteId,
+ userId: auth.user.id,
+ type,
+ name,
+ description,
+ parameters,
+ } as any);
+
+ return json(result);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ reportId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { reportId } = await params;
+ const report = await getReport(reportId);
+
+ if (!(await canDeleteWebsite(auth, report.websiteId))) {
+ return unauthorized();
+ }
+
+ await deleteReport(reportId);
+
+ return ok();
+}
diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts
new file mode 100644
index 0000000..bd7d86d
--- /dev/null
+++ b/src/app/api/reports/attribution/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type AttributionParameters, getAttribution } from '@/queries/sql/reports/getAttribution';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/breakdown/route.ts b/src/app/api/reports/breakdown/route.ts
new file mode 100644
index 0000000..3c59314
--- /dev/null
+++ b/src/app/api/reports/breakdown/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type BreakdownParameters, getBreakdown } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/funnel/route.ts b/src/app/api/reports/funnel/route.ts
new file mode 100644
index 0000000..c13f6f1
--- /dev/null
+++ b/src/app/api/reports/funnel/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type FunnelParameters, getFunnel } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/goal/route.ts b/src/app/api/reports/goal/route.ts
new file mode 100644
index 0000000..3bd0415
--- /dev/null
+++ b/src/app/api/reports/goal/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type GoalParameters, getGoal } from '@/queries/sql/reports/getGoal';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getGoal(websiteId, parameters as GoalParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts
new file mode 100644
index 0000000..29e8531
--- /dev/null
+++ b/src/app/api/reports/journey/route.ts
@@ -0,0 +1,25 @@
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getJourney } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, parameters, filters } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const queryFilters = await getQueryFilters(filters, websiteId);
+
+ const data = await getJourney(websiteId, parameters, queryFilters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/retention/route.ts b/src/app/api/reports/retention/route.ts
new file mode 100644
index 0000000..d1a7d69
--- /dev/null
+++ b/src/app/api/reports/retention/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getRetention, type RetentionParameters } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(body.filters, websiteId);
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+
+ const data = await getRetention(websiteId, parameters as RetentionParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts
new file mode 100644
index 0000000..6a55661
--- /dev/null
+++ b/src/app/api/reports/revenue/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts
new file mode 100644
index 0000000..b0a4135
--- /dev/null
+++ b/src/app/api/reports/route.ts
@@ -0,0 +1,73 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema';
+import { canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { createReport, getReports } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ websiteId: z.uuid(),
+ type: reportTypeParam.optional(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { page, search, pageSize, websiteId, type } = query;
+ const filters = {
+ page,
+ pageSize,
+ search,
+ };
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getReports(
+ {
+ where: {
+ websiteId,
+ type,
+ website: {
+ deletedAt: null,
+ },
+ },
+ },
+ filters,
+ );
+
+ return json(data);
+}
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, type, name, description, parameters } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await createReport({
+ id: uuid(),
+ userId: auth.user.id,
+ websiteId,
+ type,
+ name,
+ description: description || '',
+ parameters,
+ });
+
+ return json(result);
+}
diff --git a/src/app/api/reports/utm/route.ts b/src/app/api/reports/utm/route.ts
new file mode 100644
index 0000000..577fdab
--- /dev/null
+++ b/src/app/api/reports/utm/route.ts
@@ -0,0 +1,37 @@
+import { UTM_PARAMS } from '@/lib/constants';
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getUTM, type UTMParameters } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(body.filters, websiteId);
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+
+ const data = {
+ utm_source: [],
+ utm_medium: [],
+ utm_campaign: [],
+ utm_term: [],
+ utm_content: [],
+ };
+
+ for (const key of UTM_PARAMS) {
+ data[key] = await getUTM(websiteId, { column: key, ...parameters } as UTMParameters, filters);
+ }
+
+ return json(data);
+}
diff --git a/src/app/api/scripts/telemetry/route.ts b/src/app/api/scripts/telemetry/route.ts
new file mode 100644
index 0000000..b19e99f
--- /dev/null
+++ b/src/app/api/scripts/telemetry/route.ts
@@ -0,0 +1,28 @@
+import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants';
+
+export async function GET() {
+ if (
+ process.env.NODE_ENV !== 'production' ||
+ process.env.DISABLE_TELEMETRY ||
+ process.env.PRIVATE_MODE
+ ) {
+ return new Response('/* telemetry disabled */', {
+ headers: {
+ 'content-type': 'text/javascript',
+ },
+ });
+ }
+
+ const script = `
+ (()=>{const i=document.createElement('img');
+ i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
+ i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
+ document.body.appendChild(i);})();
+ `;
+
+ return new Response(script.replace(/\s\s+/g, ''), {
+ headers: {
+ 'content-type': 'text/javascript',
+ },
+ });
+}
diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts
new file mode 100644
index 0000000..a0becc2
--- /dev/null
+++ b/src/app/api/send/route.ts
@@ -0,0 +1,284 @@
+import { startOfHour, startOfMonth } from 'date-fns';
+import { isbot } from 'isbot';
+import { serializeError } from 'serialize-error';
+import { z } from 'zod';
+import clickhouse from '@/lib/clickhouse';
+import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
+import { hash, secret, uuid } from '@/lib/crypto';
+import { getClientInfo, hasBlockedIp } from '@/lib/detect';
+import { createToken, parseToken } from '@/lib/jwt';
+import { fetchWebsite } from '@/lib/load';
+import { parseRequest } from '@/lib/request';
+import { badRequest, forbidden, json, serverError } from '@/lib/response';
+import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
+import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
+import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
+
+interface Cache {
+ websiteId: string;
+ sessionId: string;
+ visitId: string;
+ iat: number;
+}
+
+const schema = z.object({
+ type: z.enum(['event', 'identify']),
+ payload: z
+ .object({
+ website: z.uuid().optional(),
+ link: z.uuid().optional(),
+ pixel: z.uuid().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.optional(),
+ name: z.string().max(50).optional(),
+ tag: z.string().max(50).optional(),
+ ip: z.string().optional(),
+ userAgent: z.string().optional(),
+ timestamp: z.coerce.number().int().optional(),
+ id: z.string().optional(),
+ browser: z.string().optional(),
+ os: z.string().optional(),
+ device: z.string().optional(),
+ })
+ .refine(
+ data => {
+ const keys = [data.website, data.link, data.pixel];
+ const count = keys.filter(Boolean).length;
+ return count === 1;
+ },
+ {
+ message: 'Exactly one of website, link, or pixel must be provided',
+ path: ['website'],
+ },
+ ),
+});
+
+export async function POST(request: Request) {
+ try {
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const { type, payload } = body;
+
+ const {
+ website: websiteId,
+ pixel: pixelId,
+ link: linkId,
+ hostname,
+ screen,
+ language,
+ url,
+ referrer,
+ name,
+ data,
+ title,
+ tag,
+ timestamp,
+ id,
+ } = payload;
+
+ const sourceId = websiteId || pixelId || linkId;
+
+ // Cache check
+ let cache: Cache | null = null;
+
+ if (websiteId) {
+ const cacheHeader = request.headers.get('x-umami-cache');
+
+ if (cacheHeader) {
+ const result = await parseToken(cacheHeader, secret());
+
+ if (result) {
+ cache = result;
+ }
+ }
+
+ // Find website
+ if (!cache?.websiteId) {
+ const website = await fetchWebsite(websiteId);
+
+ if (!website) {
+ return badRequest({ message: 'Website not found.' });
+ }
+ }
+ }
+
+ // Client info
+ 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 createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
+ const now = Math.floor(Date.now() / 1000);
+
+ const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
+ const visitSalt = hash(startOfHour(createdAt).toUTCString());
+
+ const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt);
+
+ // Create a session if not found
+ if (!clickhouse.enabled && !cache?.sessionId) {
+ await createSession({
+ id: sessionId,
+ websiteId: sourceId,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ distinctId: id,
+ createdAt,
+ });
+ }
+
+ // Visit info
+ let visitId = cache?.visitId || uuid(sessionId, visitSalt);
+ let iat = cache?.iat || now;
+
+ // Expire visit after 30 minutes
+ if (!timestamp && now - iat > 1800) {
+ visitId = uuid(sessionId, visitSalt);
+ iat = now;
+ }
+
+ if (type === COLLECTION_TYPE.event) {
+ const base = hostname ? `https://${hostname}` : 'https://localhost';
+ const currentUrl = new URL(url, base);
+
+ let urlPath =
+ currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash;
+ const urlQuery = currentUrl.search.substring(1);
+ const urlDomain = currentUrl.hostname.replace(/^www./, '');
+
+ 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(/\/(?=(#.*)?$)/, '');
+ }
+
+ if (referrer) {
+ const referrerUrl = new URL(referrer, base);
+
+ referrerPath = referrerUrl.pathname;
+ referrerQuery = referrerUrl.search.substring(1);
+ referrerDomain = referrerUrl.hostname.replace(/^www\./, '');
+ }
+
+ const eventType = linkId
+ ? EVENT_TYPE.linkEvent
+ : pixelId
+ ? EVENT_TYPE.pixelEvent
+ : name
+ ? EVENT_TYPE.customEvent
+ : EVENT_TYPE.pageView;
+
+ await saveEvent({
+ websiteId: sourceId,
+ sessionId,
+ visitId,
+ eventType,
+ createdAt,
+
+ // Page
+ pageTitle: safeDecodeURIComponent(title),
+ hostname: hostname || urlDomain,
+ urlPath: safeDecodeURI(urlPath),
+ urlQuery,
+ referrerPath: safeDecodeURI(referrerPath),
+ referrerQuery,
+ referrerDomain,
+
+ // Session
+ distinctId: id,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+
+ // Events
+ eventName: name,
+ eventData: data,
+ tag,
+
+ // UTM
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ utmContent,
+ utmTerm,
+
+ // Click IDs
+ gclid,
+ fbclid,
+ msclkid,
+ ttclid,
+ lifatid,
+ twclid,
+ });
+ } else if (type === COLLECTION_TYPE.identify) {
+ if (data) {
+ await saveSessionData({
+ websiteId,
+ sessionId,
+ sessionData: data,
+ distinctId: id,
+ createdAt,
+ });
+ }
+ }
+
+ const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
+
+ return json({ cache: token, sessionId, visitId });
+ } catch (e) {
+ const error = serializeError(e);
+
+ // eslint-disable-next-line no-console
+ console.log(error);
+
+ return serverError({ errorObject: error });
+ }
+}
diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts
new file mode 100644
index 0000000..bef87c4
--- /dev/null
+++ b/src/app/api/share/[shareId]/route.ts
@@ -0,0 +1,19 @@
+import { secret } from '@/lib/crypto';
+import { createToken } from '@/lib/jwt';
+import { json, notFound } from '@/lib/response';
+import { getSharedWebsite } from '@/queries/prisma';
+
+export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
+ const { shareId } = await params;
+
+ const website = await getSharedWebsite(shareId);
+
+ if (!website) {
+ return notFound();
+ }
+
+ const data = { websiteId: website.id };
+ const token = createToken(data, secret());
+
+ return json({ ...data, token });
+}
diff --git a/src/app/api/teams/[teamId]/links/route.ts b/src/app/api/teams/[teamId]/links/route.ts
new file mode 100644
index 0000000..41e139b
--- /dev/null
+++ b/src/app/api/teams/[teamId]/links/route.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewTeam } from '@/permissions';
+import { getTeamLinks } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const links = await getTeamLinks(teamId, filters);
+
+ return json(links);
+}
diff --git a/src/app/api/teams/[teamId]/pixels/route.ts b/src/app/api/teams/[teamId]/pixels/route.ts
new file mode 100644
index 0000000..daac204
--- /dev/null
+++ b/src/app/api/teams/[teamId]/pixels/route.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewTeam } from '@/permissions';
+import { getTeamPixels } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const websites = await getTeamPixels(teamId, filters);
+
+ return json(websites);
+}
diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts
new file mode 100644
index 0000000..c334b2a
--- /dev/null
+++ b/src/app/api/teams/[teamId]/route.ts
@@ -0,0 +1,71 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, notFound, ok, unauthorized } from '@/lib/response';
+import { canDeleteTeam, canUpdateTeam, canViewTeam } from '@/permissions';
+import { deleteTeam, getTeam, updateTeam } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const team = await getTeam(teamId, { includeMembers: true });
+
+ if (!team) {
+ return notFound({ message: 'Team not found.' });
+ }
+
+ return json(team);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ name: z.string().max(50).optional(),
+ accessCode: z.string().max(50).optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const team = await updateTeam(teamId, body);
+
+ return json(team);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canDeleteTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ await deleteTeam(teamId);
+
+ return ok();
+}
diff --git a/src/app/api/teams/[teamId]/users/[userId]/route.ts b/src/app/api/teams/[teamId]/users/[userId]/route.ts
new file mode 100644
index 0000000..d09af9d
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts
@@ -0,0 +1,85 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, unauthorized } from '@/lib/response';
+import { teamRoleParam } from '@/lib/schema';
+import { canDeleteTeamUser, canUpdateTeam } from '@/permissions';
+import { deleteTeamUser, getTeamUser, updateTeamUser } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string; userId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ return json(teamUser);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string; userId: string }> },
+) {
+ const schema = z.object({
+ role: teamRoleParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (!teamUser) {
+ return badRequest({ message: 'The User does not exists on this team.' });
+ }
+
+ const user = await updateTeamUser(teamUser.id, body);
+
+ return json(user);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string; userId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canDeleteTeamUser(auth, teamId, userId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (!teamUser) {
+ return badRequest({ message: 'The User does not exists on this team.' });
+ }
+
+ await deleteTeamUser(teamId, userId);
+
+ return ok();
+}
diff --git a/src/app/api/teams/[teamId]/users/route.ts b/src/app/api/teams/[teamId]/users/route.ts
new file mode 100644
index 0000000..c129763
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/route.ts
@@ -0,0 +1,83 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams, teamRoleParam } from '@/lib/schema';
+import { canUpdateTeam, canViewTeam } from '@/permissions';
+import { createTeamUser, getTeamUser, getTeamUsers } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be a member of this team.' });
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const users = await getTeamUsers(
+ {
+ where: {
+ teamId,
+ user: {
+ deletedAt: null,
+ },
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ },
+ filters,
+ );
+
+ return json(users);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ userId: z.uuid(),
+ role: teamRoleParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const { userId, role } = body;
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (teamUser) {
+ return badRequest({ message: 'User is already a member of the Team.' });
+ }
+
+ const users = await createTeamUser(userId, teamId, role);
+
+ return json(users);
+}
diff --git a/src/app/api/teams/[teamId]/websites/route.ts b/src/app/api/teams/[teamId]/websites/route.ts
new file mode 100644
index 0000000..05c6d80
--- /dev/null
+++ b/src/app/api/teams/[teamId]/websites/route.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewTeam } from '@/permissions';
+import { getTeamWebsites } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const websites = await getTeamWebsites(teamId, filters);
+
+ return json(websites);
+}
diff --git a/src/app/api/teams/join/route.ts b/src/app/api/teams/join/route.ts
new file mode 100644
index 0000000..3ce0913
--- /dev/null
+++ b/src/app/api/teams/join/route.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { ROLES } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, notFound } from '@/lib/response';
+import { createTeamUser, findTeam, getTeamUser } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ accessCode: z.string().max(50),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { accessCode } = body;
+
+ const team = await findTeam({
+ where: {
+ accessCode,
+ },
+ });
+
+ if (!team) {
+ return notFound({ message: 'Team not found.', code: 'team-not-found' });
+ }
+
+ const teamUser = await getTeamUser(team.id, auth.user.id);
+
+ if (teamUser) {
+ return badRequest({ message: 'User is already a team member.' });
+ }
+
+ const user = await createTeamUser(auth.user.id, team.id, ROLES.teamMember);
+
+ return json(user);
+}
diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts
new file mode 100644
index 0000000..53ef592
--- /dev/null
+++ b/src/app/api/teams/route.ts
@@ -0,0 +1,55 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { canCreateTeam } from '@/permissions';
+import { createTeam, getUserTeams } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const teams = await getUserTeams(auth.user.id, filters);
+
+ return json(teams);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(50),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canCreateTeam(auth))) {
+ return unauthorized();
+ }
+
+ const { name } = body;
+
+ const team = await createTeam(
+ {
+ id: uuid(),
+ name,
+ accessCode: `team_${getRandomChars(16)}`,
+ },
+ auth.user.id,
+ );
+
+ return json(team);
+}
diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts
new file mode 100644
index 0000000..aade8aa
--- /dev/null
+++ b/src/app/api/users/[userId]/route.ts
@@ -0,0 +1,102 @@
+import { z } from 'zod';
+import { hashPassword } from '@/lib/password';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, unauthorized } from '@/lib/response';
+import { userRoleParam } from '@/lib/schema';
+import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
+import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!(await canViewUser(auth, userId))) {
+ return unauthorized();
+ }
+
+ const user = await getUser(userId);
+
+ return json(user);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const schema = z.object({
+ username: z.string().max(255).optional(),
+ password: z.string().max(255).optional(),
+ role: userRoleParam.optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!(await canUpdateUser(auth, userId))) {
+ return unauthorized();
+ }
+
+ const { username, password, role } = body;
+
+ const user = await getUser(userId);
+
+ const data: any = {};
+
+ if (password) {
+ data.password = hashPassword(password);
+ }
+
+ // Only admin can change these fields
+ if (role && auth.user.isAdmin) {
+ data.role = role;
+ }
+
+ if (username && auth.user.isAdmin) {
+ data.username = username;
+ }
+
+ // Check when username changes
+ if (data.username && user.username !== data.username) {
+ const user = await getUserByUsername(username);
+
+ if (user) {
+ return badRequest({ message: 'User already exists' });
+ }
+ }
+
+ const updated = await updateUser(userId, data);
+
+ return json(updated);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ userId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!(await canDeleteUser(auth))) {
+ return unauthorized();
+ }
+
+ if (userId === auth.user.id) {
+ return badRequest({ message: 'You cannot delete yourself.' });
+ }
+
+ await deleteUser(userId);
+
+ return ok();
+}
diff --git a/src/app/api/users/[userId]/teams/route.ts b/src/app/api/users/[userId]/teams/route.ts
new file mode 100644
index 0000000..7a834a3
--- /dev/null
+++ b/src/app/api/users/[userId]/teams/route.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { getUserTeams } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (auth.user.id !== userId && !auth.user.isAdmin) {
+ return unauthorized();
+ }
+
+ const teams = await getUserTeams(userId, query);
+
+ return json(teams);
+}
diff --git a/src/app/api/users/[userId]/websites/route.ts b/src/app/api/users/[userId]/websites/route.ts
new file mode 100644
index 0000000..1107d8e
--- /dev/null
+++ b/src/app/api/users/[userId]/websites/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ includeTeams: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!auth.user.isAdmin && auth.user.id !== userId) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ if (query.includeTeams) {
+ return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters));
+ }
+
+ return json(await getUserWebsites(userId, filters));
+}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
new file mode 100644
index 0000000..dbb114c
--- /dev/null
+++ b/src/app/api/users/route.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod';
+import { ROLES } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { hashPassword } from '@/lib/password';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { canCreateUser } from '@/permissions';
+import { createUser, getUserByUsername } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ id: z.uuid().optional(),
+ username: z.string().max(255),
+ password: z.string(),
+ role: z.string().regex(/admin|user|view-only/i),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canCreateUser(auth))) {
+ return unauthorized();
+ }
+
+ const { id, username, password, role } = body;
+
+ const existingUser = await getUserByUsername(username, { showDeleted: true });
+
+ if (existingUser) {
+ return badRequest({ message: 'User already exists' });
+ }
+
+ const user = await createUser({
+ id: id || uuid(),
+ username,
+ password: hashPassword(password),
+ role: role ?? ROLES.user,
+ });
+
+ return json(user);
+}
diff --git a/src/app/api/websites/[websiteId]/active/route.ts b/src/app/api/websites/[websiteId]/active/route.ts
new file mode 100644
index 0000000..233b97e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/active/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getActiveVisitors } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const visitors = await getActiveVisitors(websiteId);
+
+ return json(visitors);
+}
diff --git a/src/app/api/websites/[websiteId]/daterange/route.ts b/src/app/api/websites/[websiteId]/daterange/route.ts
new file mode 100644
index 0000000..14a241f
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/daterange/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteDateRange } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const dateRange = await getWebsiteDateRange(websiteId);
+
+ return json(dateRange);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts
new file mode 100644
index 0000000..54afab2
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getEventData } from '@/queries/sql/events/getEventData';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; eventId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, eventId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getEventData(websiteId, eventId);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/events/route.ts b/src/app/api/websites/[websiteId]/event-data/events/route.ts
new file mode 100644
index 0000000..eb6ee6e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/events/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataEvents } from '@/queries/sql/events/getEventDataEvents';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ event: z.string().optional(),
+ ...filterParams,
+ });
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataEvents(websiteId, {
+ ...filters,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/fields/route.ts b/src/app/api/websites/[websiteId]/event-data/fields/route.ts
new file mode 100644
index 0000000..bce6a97
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/fields/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataFields } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataFields(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/properties/route.ts b/src/app/api/websites/[websiteId]/event-data/properties/route.ts
new file mode 100644
index 0000000..52d15cf
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/properties/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataProperties } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataProperties(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/stats/route.ts b/src/app/api/websites/[websiteId]/event-data/stats/route.ts
new file mode 100644
index 0000000..042e989
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/stats/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataStats(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/values/route.ts b/src/app/api/websites/[websiteId]/event-data/values/route.ts
new file mode 100644
index 0000000..12e8f2d
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts
@@ -0,0 +1,41 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataValues } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ event: z.string(),
+ propertyName: z.string(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { propertyName } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataValues(websiteId, {
+ ...filters,
+ propertyName,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts
new file mode 100644
index 0000000..74ec3ec
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, pagingParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteEvents } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().optional(),
+ endAt: z.coerce.number().optional(),
+ ...filterParams,
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWebsiteEvents(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/events/series/route.ts b/src/app/api/websites/[websiteId]/events/series/route.ts
new file mode 100644
index 0000000..977e9c8
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/series/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ unit: unitParam.optional(),
+ timezone: timezoneParam,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventStats(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts
new file mode 100644
index 0000000..eec81c6
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/export/route.ts
@@ -0,0 +1,64 @@
+import JSZip from 'jszip';
+import Papa from 'papaparse';
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, pagingParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
+ getEventMetrics(websiteId, { type: 'event' }, filters),
+ getPageviewMetrics(websiteId, { type: 'path' }, filters),
+ getPageviewMetrics(websiteId, { type: 'referrer' }, filters),
+ getSessionMetrics(websiteId, { type: 'browser' }, filters),
+ getSessionMetrics(websiteId, { type: 'os' }, filters),
+ getSessionMetrics(websiteId, { type: 'device' }, filters),
+ getSessionMetrics(websiteId, { type: 'country' }, filters),
+ ]);
+
+ const zip = new JSZip();
+
+ const parse = (data: any) => {
+ return Papa.unparse(data, {
+ header: true,
+ skipEmptyLines: true,
+ });
+ };
+
+ zip.file('events.csv', parse(events));
+ zip.file('pages.csv', parse(pages));
+ zip.file('referrers.csv', parse(referrers));
+ zip.file('browsers.csv', parse(browsers));
+ zip.file('os.csv', parse(os));
+ zip.file('devices.csv', parse(devices));
+ zip.file('countries.csv', parse(countries));
+
+ const content = await zip.generateAsync({ type: 'nodebuffer' });
+ const base64 = content.toString('base64');
+
+ return json({ zip: base64 });
+}
diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/route.ts b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts
new file mode 100644
index 0000000..d52c177
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod';
+import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import {
+ getChannelExpandedMetrics,
+ getEventExpandedMetrics,
+ getPageviewExpandedMetrics,
+ getSessionExpandedMetrics,
+} from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ limit: z.coerce.number().optional(),
+ offset: z.coerce.number().optional(),
+ ...dateRangeParams,
+ ...searchParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type, limit, offset, search } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ if (search) {
+ filters[type] = `c.${search}`;
+ }
+
+ if (SESSION_COLUMNS.includes(type)) {
+ const data = await getSessionExpandedMetrics(websiteId, { type, limit, offset }, filters);
+
+ return json(data);
+ }
+
+ if (EVENT_COLUMNS.includes(type)) {
+ if (type === 'event') {
+ filters.eventType = EVENT_TYPE.customEvent;
+ return json(await getEventExpandedMetrics(websiteId, { type, limit, offset }, filters));
+ } else {
+ return json(await getPageviewExpandedMetrics(websiteId, { type, limit, offset }, filters));
+ }
+ }
+
+ if (type === 'channel') {
+ return json(await getChannelExpandedMetrics(websiteId, filters));
+ }
+
+ return badRequest();
+}
diff --git a/src/app/api/websites/[websiteId]/metrics/route.ts b/src/app/api/websites/[websiteId]/metrics/route.ts
new file mode 100644
index 0000000..12784ad
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/metrics/route.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod';
+import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import {
+ getChannelMetrics,
+ getEventMetrics,
+ getPageviewMetrics,
+ getSessionMetrics,
+} from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ limit: z.coerce.number().optional(),
+ offset: z.coerce.number().optional(),
+ ...dateRangeParams,
+ ...searchParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type, limit, offset, search } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ if (search) {
+ filters[type] = `c.${search}`;
+ }
+
+ if (SESSION_COLUMNS.includes(type)) {
+ const data = await getSessionMetrics(websiteId, { type, limit, offset }, filters);
+
+ return json(data);
+ }
+
+ if (EVENT_COLUMNS.includes(type)) {
+ if (type === 'event') {
+ filters.eventType = EVENT_TYPE.customEvent;
+ return json(await getEventMetrics(websiteId, { type, limit, offset }, filters));
+ } else {
+ return json(await getPageviewMetrics(websiteId, { type, limit, offset }, filters));
+ }
+ }
+
+ if (type === 'channel') {
+ return json(await getChannelMetrics(websiteId, filters));
+ }
+
+ return badRequest();
+}
diff --git a/src/app/api/websites/[websiteId]/pageviews/route.ts b/src/app/api/websites/[websiteId]/pageviews/route.ts
new file mode 100644
index 0000000..af59bce
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/pageviews/route.ts
@@ -0,0 +1,72 @@
+import { z } from 'zod';
+import { getCompareDate } from '@/lib/date';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getPageviewStats, getSessionStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const [pageviews, sessions] = await Promise.all([
+ getPageviewStats(websiteId, filters),
+ getSessionStats(websiteId, filters),
+ ]);
+
+ if (filters.compare) {
+ const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
+ filters.compare,
+ filters.startDate,
+ filters.endDate,
+ );
+
+ const [comparePageviews, compareSessions] = await Promise.all([
+ getPageviewStats(websiteId, {
+ ...filters,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ }),
+ getSessionStats(websiteId, {
+ ...filters,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ }),
+ ]);
+
+ return json({
+ pageviews,
+ sessions,
+ startDate: filters.startDate,
+ endDate: filters.endDate,
+ compare: {
+ pageviews: comparePageviews,
+ sessions: compareSessions,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ },
+ });
+ }
+
+ return json({ pageviews, sessions });
+}
diff --git a/src/app/api/websites/[websiteId]/reports/route.ts b/src/app/api/websites/[websiteId]/reports/route.ts
new file mode 100644
index 0000000..93e7ab4
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/reports/route.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, pagingParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getReports } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+ filters: { type: string },
+) {
+ const schema = z.object({
+ ...filterParams,
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { page, pageSize, search } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getReports(
+ {
+ where: {
+ websiteId,
+ type: filters.type,
+ },
+ },
+ {
+ page,
+ pageSize,
+ search,
+ },
+ );
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/reset/route.ts b/src/app/api/websites/[websiteId]/reset/route.ts
new file mode 100644
index 0000000..e0be5a5
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/reset/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { ok, unauthorized } from '@/lib/response';
+import { canUpdateWebsite } from '@/permissions';
+import { resetWebsite } from '@/queries/prisma';
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ await resetWebsite(websiteId);
+
+ return ok();
+}
diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts
new file mode 100644
index 0000000..b4c0e7e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -0,0 +1,84 @@
+import { z } from 'zod';
+import { SHARE_ID_REGEX } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const website = await getWebsite(websiteId);
+
+ return json(website);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ name: z.string().optional(),
+ domain: z.string().optional(),
+ shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { name, domain, shareId } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ try {
+ const website = await updateWebsite(websiteId, { name, domain, shareId });
+
+ return Response.json(website);
+ } catch (e: any) {
+ if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
+ return badRequest({ message: 'That share ID is already taken.' });
+ }
+
+ return serverError(e);
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canDeleteWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ await deleteWebsite(websiteId);
+
+ return ok();
+}
diff --git a/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts
new file mode 100644
index 0000000..b51f783
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts
@@ -0,0 +1,92 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, notFound, ok, unauthorized } from '@/lib/response';
+import { anyObjectParam, segmentTypeParam } from '@/lib/schema';
+import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { deleteSegment, getSegment, updateSegment } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; segmentId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, segmentId } = await params;
+
+ const segment = await getSegment(segmentId);
+
+ if (websiteId && !(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ return json(segment);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; segmentId: string }> },
+) {
+ const schema = z.object({
+ type: segmentTypeParam,
+ name: z.string().max(200),
+ parameters: anyObjectParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, segmentId } = await params;
+ const { type, name, parameters } = body;
+
+ const segment = await getSegment(segmentId);
+
+ if (!segment) {
+ return notFound();
+ }
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await updateSegment(segmentId, {
+ type,
+ name,
+ parameters,
+ } as any);
+
+ return json(result);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; segmentId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, segmentId } = await params;
+
+ const segment = await getSegment(segmentId);
+
+ if (!segment) {
+ return notFound();
+ }
+
+ if (!(await canDeleteWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ await deleteSegment(segmentId);
+
+ return ok();
+}
diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts
new file mode 100644
index 0000000..4592765
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/segments/route.ts
@@ -0,0 +1,70 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema';
+import { canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { createSegment, getWebsiteSegments } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: segmentTypeParam,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { type } = query;
+
+ if (websiteId && !(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const segments = await getWebsiteSegments(websiteId, type, filters);
+
+ return json(segments);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: segmentTypeParam,
+ name: z.string().max(200),
+ parameters: anyObjectParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { type, name, parameters } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await createSegment({
+ id: uuid(),
+ websiteId,
+ type,
+ name,
+ parameters,
+ } as any);
+
+ return json(result);
+}
diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts
new file mode 100644
index 0000000..2d8db15
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getSessionDataProperties } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getSessionDataProperties(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/session-data/values/route.ts b/src/app/api/websites/[websiteId]/session-data/values/route.ts
new file mode 100644
index 0000000..7d06870
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getSessionDataValues } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ propertyName: z.string().optional(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { propertyName } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getSessionDataValues(websiteId, {
+ ...filters,
+ propertyName,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts
new file mode 100644
index 0000000..41b766d
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getSessionActivity } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; sessionId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, sessionId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getSessionActivity(websiteId, sessionId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts
new file mode 100644
index 0000000..6b5c241
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getSessionData } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; sessionId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, sessionId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getSessionData(websiteId, sessionId);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts
new file mode 100644
index 0000000..1091663
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSession } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; sessionId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, sessionId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getWebsiteSession(websiteId, sessionId);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/route.ts b/src/app/api/websites/[websiteId]/sessions/route.ts
new file mode 100644
index 0000000..ed4757a
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, pagingParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSessions } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...filterParams,
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWebsiteSessions(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/stats/route.ts b/src/app/api/websites/[websiteId]/sessions/stats/route.ts
new file mode 100644
index 0000000..459830e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/stats/route.ts
@@ -0,0 +1,42 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSessionStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const metrics = await getWebsiteSessionStats(websiteId, filters);
+
+ const data = Object.keys(metrics[0]).reduce((obj, key) => {
+ obj[key] = {
+ value: Number(metrics[0][key]) || 0,
+ };
+ return obj;
+ }, {});
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts
new file mode 100644
index 0000000..b9ccf3e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, timezoneParam } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWeeklyTraffic } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ timezone: timezoneParam,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWeeklyTraffic(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts
new file mode 100644
index 0000000..07c8b96
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/stats/route.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod';
+import { getCompareDate } from '@/lib/date';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWebsiteStats(websiteId, filters);
+
+ const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
+
+ const comparison = await getWebsiteStats(websiteId, {
+ ...filters,
+ startDate,
+ endDate,
+ });
+
+ return json({ ...data, comparison });
+}
diff --git a/src/app/api/websites/[websiteId]/transfer/route.ts b/src/app/api/websites/[websiteId]/transfer/route.ts
new file mode 100644
index 0000000..df2fed2
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/transfer/route.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from '@/permissions';
+import { updateWebsite } from '@/queries/prisma';
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ userId: z.uuid().optional(),
+ teamId: z.uuid().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { userId, teamId } = body;
+
+ if (userId) {
+ if (!(await canTransferWebsiteToUser(auth, websiteId, userId))) {
+ return unauthorized();
+ }
+
+ const website = await updateWebsite(websiteId, {
+ userId,
+ teamId: null,
+ });
+
+ return json(website);
+ } else if (teamId) {
+ if (!(await canTransferWebsiteToTeam(auth, websiteId, teamId))) {
+ return unauthorized();
+ }
+
+ const website = await updateWebsite(websiteId, {
+ userId: null,
+ teamId,
+ });
+
+ return json(website);
+ }
+
+ return badRequest();
+}
diff --git a/src/app/api/websites/[websiteId]/values/route.ts b/src/app/api/websites/[websiteId]/values/route.ts
new file mode 100644
index 0000000..172325e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/values/route.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { EVENT_COLUMNS, FILTER_COLUMNS, SEGMENT_TYPES, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, fieldsParam, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSegments } from '@/queries/prisma';
+import { getValues } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: fieldsParam,
+ ...dateRangeParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type } = query;
+
+ if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !SEGMENT_TYPES[type]) {
+ return badRequest();
+ }
+
+ let values: any[];
+
+ if (SEGMENT_TYPES[type]) {
+ values = (await getWebsiteSegments(websiteId, type))?.data?.map(segment => ({
+ value: segment.name,
+ }));
+ } else {
+ const filters = await getQueryFilters(query, websiteId);
+ values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
+ }
+
+ return json(values.filter(n => n).sort());
+}
diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts
new file mode 100644
index 0000000..e2b26c1
--- /dev/null
+++ b/src/app/api/websites/route.ts
@@ -0,0 +1,86 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import redis from '@/lib/redis';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
+import { createWebsite, getWebsiteCount } from '@/queries/prisma';
+import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
+
+const CLOUD_WEBSITE_LIMIT = 3;
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ includeTeams: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const userId = auth.user.id;
+
+ const filters = await getQueryFilters(query);
+
+ if (query.includeTeams) {
+ return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters));
+ }
+
+ return json(await getUserWebsites(userId, filters));
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(100),
+ domain: z.string().max(500),
+ shareId: z.string().max(50).nullable().optional(),
+ teamId: z.uuid().nullable().optional(),
+ id: z.uuid().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { id, name, domain, shareId, teamId } = body;
+
+ if (process.env.CLOUD_MODE && !teamId) {
+ const account = await redis.client.get(`account:${auth.user.id}`);
+
+ if (!account?.hasSubscription) {
+ const count = await getWebsiteCount(auth.user.id);
+
+ if (count >= CLOUD_WEBSITE_LIMIT) {
+ return unauthorized({ message: 'Website limit reached.' });
+ }
+ }
+ }
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ id: id ?? uuid(),
+ createdBy: auth.user.id,
+ name,
+ domain,
+ shareId,
+ teamId,
+ };
+
+ if (!teamId) {
+ data.userId = auth.user.id;
+ }
+
+ const website = await createWebsite(data);
+
+ return json(website);
+}