{showHeader && (
{formatTimezoneDate(createdAt, 'PPPP')}
)}
@@ -44,7 +45,7 @@ export function SessionActivity({
{eventName ? : }
{eventName || urlPath}
- >
+
);
})}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx
index 39b6afd1f..56d4a0d91 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx
@@ -1,9 +1,9 @@
import { TextOverflow } from 'react-basics';
-import { useMessages, useSessionData } from 'components/hooks';
-import Empty from 'components/common/Empty';
-import { DATA_TYPES } from 'lib/constants';
+import { useMessages, useSessionData } from '@/components/hooks';
+import Empty from '@/components/common/Empty';
+import { DATA_TYPES } from '@/lib/constants';
import styles from './SessionData.module.css';
-import { LoadingPanel } from 'components/common/LoadingPanel';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
const { formatMessage, labels } = useMessages();
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx
index d6a07edcf..9ccf275f6 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx
@@ -1,7 +1,7 @@
'use client';
-import Avatar from 'components/common/Avatar';
-import { LoadingPanel } from 'components/common/LoadingPanel';
-import { useWebsiteSession } from 'components/hooks';
+import Avatar from '@/components/common/Avatar';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useWebsiteSession } from '@/components/hooks';
import WebsiteHeader from '../../WebsiteHeader';
import { SessionActivity } from './SessionActivity';
import { SessionData } from './SessionData';
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx
index edd0353e3..3ce78d486 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx
@@ -1,7 +1,7 @@
-import { useFormat, useLocale, useMessages, useRegionNames, useTimezone } from 'components/hooks';
-import TypeIcon from 'components/common/TypeIcon';
+import { useFormat, useLocale, useMessages, useRegionNames, useTimezone } from '@/components/hooks';
+import TypeIcon from '@/components/common/TypeIcon';
import { Icon, CopyIcon } from 'react-basics';
-import Icons from 'components/icons';
+import Icons from '@/components/icons';
import styles from './SessionInfo.module.css';
export default function SessionInfo({ data }) {
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx
index ea606582f..eb385e9bb 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx
@@ -1,7 +1,7 @@
-import { useMessages } from 'components/hooks';
-import MetricCard from 'components/metrics/MetricCard';
-import MetricsBar from 'components/metrics/MetricsBar';
-import { formatShortTime } from 'lib/format';
+import { useMessages } from '@/components/hooks';
+import MetricCard from '@/components/metrics/MetricCard';
+import MetricsBar from '@/components/metrics/MetricsBar';
+import { formatShortTime } from '@/lib/format';
export function SessionStats({ data }) {
const { formatMessage, labels } = useMessages();
diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx
index bbc10a35c..66884c2fc 100644
--- a/src/app/Providers.tsx
+++ b/src/app/Providers.tsx
@@ -2,8 +2,8 @@
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactBasicsProvider } from 'react-basics';
-import ErrorBoundary from 'components/common/ErrorBoundary';
-import { useLocale } from 'components/hooks';
+import ErrorBoundary from '@/components/common/ErrorBoundary';
+import { useLocale } from '@/components/hooks';
import 'chartjs-adapter-date-fns';
import { useEffect } from 'react';
diff --git a/src/app/actions/getConfig.ts b/src/app/actions/getConfig.ts
new file mode 100644
index 000000000..bb892f01e
--- /dev/null
+++ b/src/app/actions/getConfig.ts
@@ -0,0 +1,10 @@
+'use server';
+
+export async function getConfig() {
+ return {
+ telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
+ trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
+ uiDisabled: !!process.env.DISABLE_UI,
+ updatesDisabled: !!process.env.DISABLE_UPDATES,
+ };
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 000000000..2185e03e7
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { canViewUsers } from '@/lib/auth';
+import { getUsers } from '@/queries/prisma/user';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ 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: {
+ websiteUser: {
+ where: { deletedAt: null },
+ },
+ },
+ },
+ },
+ },
+ 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 000000000..3f35ea49d
--- /dev/null
+++ b/src/app/api/admin/websites/route.ts
@@ -0,0 +1,90 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { canViewAllWebsites } from '@/lib/auth';
+import { getWebsites } from '@/queries/prisma/website';
+import { ROLES } from '@/lib/constants';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ userId: z.string().uuid(),
+ includeOwnedTeams: z.string().optional(),
+ includeAllTeams: z.string().optional(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewAllWebsites(auth))) {
+ return unauthorized();
+ }
+
+ const { userId, includeOwnedTeams, includeAllTeams } = query;
+
+ const websites = await getWebsites(
+ {
+ where: {
+ OR: [
+ ...(userId && [{ userId }]),
+ ...(userId && includeOwnedTeams
+ ? [
+ {
+ team: {
+ deletedAt: null,
+ teamUser: {
+ some: {
+ role: ROLES.teamOwner,
+ userId,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ...(userId && includeAllTeams
+ ? [
+ {
+ team: {
+ deletedAt: null,
+ teamUser: {
+ some: {
+ userId,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ include: {
+ user: {
+ select: {
+ username: true,
+ id: true,
+ },
+ },
+ team: {
+ where: {
+ deletedAt: null,
+ },
+ include: {
+ teamUser: {
+ where: {
+ role: ROLES.teamOwner,
+ },
+ },
+ },
+ },
+ },
+ },
+ 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 000000000..7ae22e2bd
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { checkPassword } from '@/lib/auth';
+import { createSecureToken } from '@/lib/jwt';
+import redis from '@/lib/redis';
+import { getUserByUsername } from '@/queries';
+import { json, unauthorized } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { saveAuth } from '@/lib/auth';
+import { secret } from '@/lib/crypto';
+import { ROLES } from '@/lib/constants';
+
+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();
+ }
+
+ 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());
+ }
+
+ return json({
+ token,
+ user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },
+ });
+}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 000000000..7bf0a8137
--- /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 000000000..fc8fb9bf3
--- /dev/null
+++ b/src/app/api/auth/sso/route.ts
@@ -0,0 +1,18 @@
+import redis from '@/lib/redis';
+import { json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { saveAuth } from '@/lib/auth';
+
+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 000000000..4d98b5543
--- /dev/null
+++ b/src/app/api/auth/verify/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.user);
+}
diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts
new file mode 100644
index 000000000..914630893
--- /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/me/password/route.ts b/src/app/api/me/password/route.ts
new file mode 100644
index 000000000..69bef49b3
--- /dev/null
+++ b/src/app/api/me/password/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { checkPassword, hashPassword } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { json, badRequest } 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('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 000000000..59a325525
--- /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 000000000..2ea6575eb
--- /dev/null
+++ b/src/app/api/me/teams/route.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod';
+import { pagingParams } from '@/lib/schema';
+import { getUserTeams } from '@/queries';
+import { json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const teams = await getUserTeams(auth.user.id, query);
+
+ 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 000000000..a8df856a4
--- /dev/null
+++ b/src/app/api/me/websites/route.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod';
+import { pagingParams } from '@/lib/schema';
+import { getUserWebsites } from '@/queries';
+import { json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const websites = await getUserWebsites(auth.user.id, query);
+
+ return json(websites);
+}
diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts
new file mode 100644
index 000000000..7f9c1a9a2
--- /dev/null
+++ b/src/app/api/realtime/[websiteId]/route.ts
@@ -0,0 +1,30 @@
+import { json, unauthorized } from '@/lib/response';
+import { getRealtimeData } from '@/queries';
+import { canViewWebsite } from '@/lib/auth';
+import { startOfMinute, subMinutes } from 'date-fns';
+import { REALTIME_RANGE } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+
+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;
+ const { timezone } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
+
+ const data = await getRealtimeData(websiteId, { startDate, timezone });
+
+ 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 000000000..ba90ee082
--- /dev/null
+++ b/src/app/api/reports/[reportId]/route.ts
@@ -0,0 +1,91 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { deleteReport, getReport, updateReport } from '@/queries';
+import { canDeleteReport, canUpdateReport, canViewReport } from '@/lib/auth';
+import { unauthorized, json, notFound, ok } from '@/lib/response';
+import { reportTypeParam } from '@/lib/schema';
+
+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();
+ }
+
+ report.parameters = JSON.parse(report.parameters);
+
+ return json(report);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ reportId: string }> },
+) {
+ const schema = z.object({
+ websiteId: z.string().uuid(),
+ type: reportTypeParam,
+ name: z.string().max(200),
+ description: z.string().max(500),
+ parameters: z.object({}).passthrough(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ 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 canUpdateReport(auth, report))) {
+ return unauthorized();
+ }
+
+ const result = await updateReport(reportId, {
+ websiteId,
+ userId: auth.user.id,
+ type,
+ name,
+ description,
+ parameters: JSON.stringify(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 canDeleteReport(auth, report))) {
+ return unauthorized();
+ }
+
+ await deleteReport(reportId);
+
+ return ok();
+}
diff --git a/src/app/api/reports/funnel/route.ts b/src/app/api/reports/funnel/route.ts
new file mode 100644
index 000000000..6033c6333
--- /dev/null
+++ b/src/app/api/reports/funnel/route.ts
@@ -0,0 +1,47 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getFunnel } from '@/queries';
+import { reportParms } from '@/lib/schema';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ window: z.coerce.number().positive(),
+ steps: z
+ .array(
+ z.object({
+ type: z.string(),
+ value: z.string(),
+ }),
+ )
+ .min(2),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ steps,
+ window,
+ dateRange: { startDate, endDate },
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getFunnel(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ steps,
+ windowMinutes: +window,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/reports/goals/route.ts b/src/app/api/reports/goals/route.ts
new file mode 100644
index 000000000..5a2f6bd0c
--- /dev/null
+++ b/src/app/api/reports/goals/route.ts
@@ -0,0 +1,57 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getGoals } from '@/queries/sql/reports/getGoals';
+import { reportParms } from '@/lib/schema';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ goals: z
+ .array(
+ z
+ .object({
+ type: z.string().regex(/url|event|event-data/),
+ value: z.string(),
+ goal: z.coerce.number(),
+ operator: z
+ .string()
+ .regex(/count|sum|average/)
+ .optional(),
+ property: z.string().optional(),
+ })
+ .refine(data => {
+ if (data['type'] === 'event-data') {
+ return data['operator'] && data['property'];
+ }
+ return true;
+ }),
+ )
+ .min(1),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ goals,
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getGoals(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ goals,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/reports/insights/route.ts b/src/app/api/reports/insights/route.ts
new file mode 100644
index 000000000..b3569cba6
--- /dev/null
+++ b/src/app/api/reports/insights/route.ts
@@ -0,0 +1,62 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getInsights } from '@/queries';
+import { reportParms } from '@/lib/schema';
+
+function convertFilters(filters: any[]) {
+ return filters.reduce((obj, filter) => {
+ obj[filter.name] = filter;
+
+ return obj;
+ }, {});
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ fields: z
+ .array(
+ z.object({
+ name: z.string(),
+ type: z.string(),
+ label: z.string(),
+ }),
+ )
+ .min(1),
+ filters: z.array(
+ z.object({
+ name: z.string(),
+ type: z.string(),
+ operator: z.string(),
+ value: z.string(),
+ }),
+ ),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ fields,
+ filters,
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getInsights(websiteId, fields, {
+ ...convertFilters(filters),
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ });
+
+ 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 000000000..a1bc62901
--- /dev/null
+++ b/src/app/api/reports/journey/route.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getJourney } from '@/queries';
+import { reportParms } from '@/lib/schema';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ steps: z.coerce.number().min(3).max(7),
+ startStep: z.string(),
+ endStep: z.string(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ steps,
+ startStep,
+ endStep,
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getJourney(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ steps,
+ startStep,
+ endStep,
+ });
+
+ 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 000000000..83220bb4c
--- /dev/null
+++ b/src/app/api/reports/retention/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getRetention } from '@/queries';
+import { reportParms, timezoneParam } from '@/lib/schema';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ timezone: timezoneParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ timezone,
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getRetention(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ timezone,
+ });
+
+ 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 000000000..13a34f382
--- /dev/null
+++ b/src/app/api/reports/revenue/route.ts
@@ -0,0 +1,63 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { reportParms, timezoneParam } from '@/lib/schema';
+import { getRevenue } from '@/queries/sql/reports/getRevenue';
+import { getRevenueValues } from '@/queries/sql/reports/getRevenueValues';
+
+export async function GET(request: Request) {
+ const { auth, query, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, startDate, endDate } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getRevenueValues(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ });
+
+ return json(data);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ currency: z.string(),
+ ...reportParms,
+ timezone: timezoneParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ currency,
+ timezone,
+ dateRange: { startDate, endDate, unit },
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getRevenue(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ unit,
+ timezone,
+ currency,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts
new file mode 100644
index 000000000..e50c57bc2
--- /dev/null
+++ b/src/app/api/reports/route.ts
@@ -0,0 +1,110 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { pagingParams, reportTypeParam } from '@/lib/schema';
+import { parseRequest } from '@/lib/request';
+import { canViewTeam, canViewWebsite, canUpdateWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { getReports, createReport } from '@/queries';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ websiteId: z.string().uuid().optional(),
+ teamId: z.string().uuid().optional(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { page, search, pageSize, websiteId, teamId } = query;
+ const userId = auth.user.id;
+ const filters = {
+ page,
+ pageSize,
+ search,
+ };
+
+ if (
+ (websiteId && !(await canViewWebsite(auth, websiteId))) ||
+ (teamId && !(await canViewTeam(auth, teamId)))
+ ) {
+ return unauthorized();
+ }
+
+ const data = await getReports(
+ {
+ where: {
+ OR: [
+ ...(websiteId ? [{ websiteId }] : []),
+ ...(teamId
+ ? [
+ {
+ website: {
+ deletedAt: null,
+ teamId,
+ },
+ },
+ ]
+ : []),
+ ...(userId && !websiteId && !teamId
+ ? [
+ {
+ website: {
+ deletedAt: null,
+ userId,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ include: {
+ website: {
+ select: {
+ domain: true,
+ },
+ },
+ },
+ },
+ filters,
+ );
+
+ return json(data);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ websiteId: z.string().uuid(),
+ name: z.string().max(200),
+ type: reportTypeParam,
+ description: z.string().max(500),
+ parameters: z.object({}).passthrough(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ 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,
+ parameters: JSON.stringify(parameters),
+ } as any);
+
+ 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 000000000..38e88a6de
--- /dev/null
+++ b/src/app/api/reports/utm/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getUTM } from '@/queries';
+import { reportParms } from '@/lib/schema';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ dateRange: { startDate, endDate, timezone },
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getUTM(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ timezone,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/scripts/telemetry/route.ts b/src/app/api/scripts/telemetry/route.ts
index ecd83fcb1..54cee5656 100644
--- a/src/app/api/scripts/telemetry/route.ts
+++ b/src/app/api/scripts/telemetry/route.ts
@@ -1,4 +1,4 @@
-import { CURRENT_VERSION, TELEMETRY_PIXEL } from 'lib/constants';
+import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants';
export async function GET() {
if (
diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts
new file mode 100644
index 000000000..2d220f38c
--- /dev/null
+++ b/src/app/api/send/route.ts
@@ -0,0 +1,203 @@
+import { z } from 'zod';
+import { isbot } from 'isbot';
+import { createToken, parseToken } from '@/lib/jwt';
+import clickhouse from '@/lib/clickhouse';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, forbidden, serverError } from '@/lib/response';
+import { fetchSession, fetchWebsite } from '@/lib/load';
+import { getClientInfo, hasBlockedIp } from '@/lib/detect';
+import { secret, uuid, visitSalt } from '@/lib/crypto';
+import { COLLECTION_TYPE, DOMAIN_REGEX } from '@/lib/constants';
+import { createSession, saveEvent, saveSessionData } from '@/queries';
+import { urlOrPathParam } from '@/lib/schema';
+
+const schema = z.object({
+ type: z.enum(['event', 'identify']),
+ payload: z.object({
+ website: z.string().uuid(),
+ data: z.object({}).passthrough().optional(),
+ hostname: z.string().regex(DOMAIN_REGEX).max(100).optional(),
+ language: z.string().max(35).optional(),
+ referrer: urlOrPathParam.optional(),
+ screen: z.string().max(11).optional(),
+ title: z.string().optional(),
+ url: urlOrPathParam,
+ name: z.string().max(50).optional(),
+ tag: z.string().max(50).optional(),
+ ip: z.string().ip().optional(),
+ userAgent: z.string().optional(),
+ }),
+});
+
+export async function POST(request: Request) {
+ try {
+ // Bot check
+ if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
+ return json({ beep: 'boop' });
+ }
+
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const { type, payload } = body;
+
+ const {
+ website: websiteId,
+ hostname,
+ screen,
+ language,
+ url,
+ referrer,
+ name,
+ data,
+ title,
+ tag,
+ } = payload;
+
+ // Cache check
+ let cache: { websiteId: string; sessionId: string; visitId: string; iat: number } | null = null;
+ 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('Website not found.');
+ }
+ }
+
+ // Client info
+ const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
+ await getClientInfo(request, payload);
+
+ // IP block
+ if (hasBlockedIp(ip)) {
+ return forbidden();
+ }
+
+ const sessionId = uuid(websiteId, hostname, ip, userAgent);
+
+ // Find session
+ if (!clickhouse.enabled && !cache?.sessionId) {
+ const session = await fetchSession(websiteId, sessionId);
+
+ // Create a session if not found
+ if (!session) {
+ try {
+ await createSession({
+ id: sessionId,
+ websiteId,
+ hostname,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ subdivision1,
+ subdivision2,
+ city,
+ });
+ } catch (e: any) {
+ if (!e.message.toLowerCase().includes('unique constraint')) {
+ return serverError(e);
+ }
+ }
+ }
+ }
+
+ // Visit info
+ const now = Math.floor(new Date().getTime() / 1000);
+ let visitId = cache?.visitId || uuid(sessionId, visitSalt());
+ let iat = cache?.iat || now;
+
+ // Expire visit after 30 minutes
+ if (now - iat > 1800) {
+ visitId = uuid(sessionId, visitSalt());
+ iat = now;
+ }
+
+ if (type === COLLECTION_TYPE.event) {
+ const base = hostname ? `https://${hostname}` : 'https://localhost';
+ const currentUrl = new URL(url, base);
+
+ let urlPath = currentUrl.pathname;
+ const urlQuery = currentUrl.search.substring(1);
+ const urlDomain = currentUrl.hostname.replace(/^www./, '');
+
+ if (process.env.REMOVE_TRAILING_SLASH) {
+ urlPath = urlPath.replace(/(.+)\/$/, '$1');
+ }
+
+ let referrerPath: string;
+ let referrerQuery: string;
+ let referrerDomain: string;
+
+ if (referrer) {
+ const referrerUrl = new URL(referrer, base);
+
+ referrerPath = referrerUrl.pathname;
+ referrerQuery = referrerUrl.search.substring(1);
+
+ if (referrerUrl.hostname !== 'localhost') {
+ referrerDomain = referrerUrl.hostname.replace(/^www\./, '');
+ }
+ }
+
+ await saveEvent({
+ websiteId,
+ sessionId,
+ visitId,
+ urlPath,
+ urlQuery,
+ referrerPath,
+ referrerQuery,
+ referrerDomain,
+ pageTitle: title,
+ eventName: name,
+ eventData: data,
+ hostname: hostname || urlDomain,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ subdivision1,
+ subdivision2,
+ city,
+ tag,
+ });
+ }
+
+ if (type === COLLECTION_TYPE.identify) {
+ if (!data) {
+ return badRequest('Data required.');
+ }
+
+ await saveSessionData({
+ websiteId,
+ sessionId,
+ sessionData: data,
+ });
+ }
+
+ const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
+
+ return json({ cache: token });
+ } catch (e) {
+ return serverError(e);
+ }
+}
diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts
new file mode 100644
index 000000000..e387938d2
--- /dev/null
+++ b/src/app/api/share/[shareId]/route.ts
@@ -0,0 +1,19 @@
+import { json, notFound } from '@/lib/response';
+import { createToken } from '@/lib/jwt';
+import { secret } from '@/lib/crypto';
+import { getSharedWebsite } from '@/queries';
+
+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]/route.ts b/src/app/api/teams/[teamId]/route.ts
new file mode 100644
index 000000000..f7f4b3316
--- /dev/null
+++ b/src/app/api/teams/[teamId]/route.ts
@@ -0,0 +1,71 @@
+import { z } from 'zod';
+import { unauthorized, json, notFound, ok } from '@/lib/response';
+import { canDeleteTeam, canUpdateTeam, canViewTeam } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { deleteTeam, getTeam, updateTeam } from '@/queries';
+
+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('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),
+ accessCode: z.string().max(50),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized('You must be the owner 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('You must be the owner 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 000000000..bf5f4d364
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts
@@ -0,0 +1,84 @@
+import { canDeleteTeamUser, canUpdateTeam } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, unauthorized } from '@/lib/response';
+import { deleteTeamUser, getTeamUser, updateTeamUser } from '@/queries';
+import { z } from 'zod';
+
+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('You must be the owner 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: z.string().regex(/team-member|team-view-only|team-manager/),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized('You must be the owner of this team.');
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (!teamUser) {
+ return badRequest('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('You must be the owner of this team.');
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (!teamUser) {
+ return badRequest('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 000000000..57460b89e
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/route.ts
@@ -0,0 +1,77 @@
+import { z } from 'zod';
+import { unauthorized, json, badRequest } from '@/lib/response';
+import { canAddUserToTeam, canViewTeam } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { pagingParams, roleParam } from '@/lib/schema';
+import { createTeamUser, getTeamUser, getTeamUsers } from '@/queries';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized('You must be the owner of this team.');
+ }
+
+ const users = await getTeamUsers(
+ {
+ where: {
+ teamId,
+ user: {
+ deletedAt: null,
+ },
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ },
+ query,
+ );
+
+ return json(users);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ userId: z.string().uuid(),
+ role: roleParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canAddUserToTeam(auth))) {
+ return unauthorized();
+ }
+
+ const { userId, role } = body;
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (teamUser) {
+ return badRequest('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 000000000..f69ab4658
--- /dev/null
+++ b/src/app/api/teams/[teamId]/websites/route.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+import { unauthorized, json } from '@/lib/response';
+import { canViewTeam } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { pagingParams } from '@/lib/schema';
+import { getTeamWebsites } from '@/queries';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const websites = await getTeamWebsites(teamId, query);
+
+ 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 000000000..3464054cf
--- /dev/null
+++ b/src/app/api/teams/join/route.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod';
+import { unauthorized, json, badRequest, notFound } from '@/lib/response';
+import { canCreateTeam } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { ROLES } from '@/lib/constants';
+import { createTeamUser, findTeam, getTeamUser } from '@/queries';
+
+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();
+ }
+
+ if (!(await canCreateTeam(auth))) {
+ return unauthorized();
+ }
+
+ const { accessCode } = body;
+
+ const team = await findTeam({
+ where: {
+ accessCode,
+ },
+ });
+
+ if (!team) {
+ return notFound('Team not found.');
+ }
+
+ const teamUser = await getTeamUser(team.id, auth.user.id);
+
+ if (teamUser) {
+ return badRequest('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 000000000..d319d87b8
--- /dev/null
+++ b/src/app/api/teams/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { getRandomChars } from '@/lib/crypto';
+import { unauthorized, json } from '@/lib/response';
+import { canCreateTeam } from '@/lib/auth';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { createTeam } from '@/queries';
+
+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 000000000..abb3331d0
--- /dev/null
+++ b/src/app/api/users/[userId]/route.ts
@@ -0,0 +1,101 @@
+import { z } from 'zod';
+import { canUpdateUser, canViewUser, canDeleteUser } from '@/lib/auth';
+import { getUser, getUserByUsername, updateUser, deleteUser } from '@/queries';
+import { json, unauthorized, badRequest, ok } from '@/lib/response';
+import { hashPassword } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+
+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),
+ password: z.string().max(255),
+ role: z.string().regex(/admin|user|view-only/i),
+ });
+
+ 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('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('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 000000000..ff6595250
--- /dev/null
+++ b/src/app/api/users/[userId]/teams/route.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+import { pagingParams } from '@/lib/schema';
+import { getUserTeams } from '@/queries';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+
+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]/usage/route.ts b/src/app/api/users/[userId]/usage/route.ts
new file mode 100644
index 000000000..e6ff217d6
--- /dev/null
+++ b/src/app/api/users/[userId]/usage/route.ts
@@ -0,0 +1,63 @@
+import { z } from 'zod';
+import { json, unauthorized } from '@/lib/response';
+import { getAllUserWebsitesIncludingTeamOwner } from '@/queries/prisma/website';
+import { getEventUsage } from '@/queries/sql/events/getEventUsage';
+import { getEventDataUsage } from '@/queries/sql/events/getEventDataUsage';
+import { parseRequest } from '@/lib/request';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: 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();
+ }
+
+ if (!auth.user.isAdmin) {
+ return unauthorized();
+ }
+
+ const { userId } = await params;
+ const { startAt, endAt } = query;
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const websites = await getAllUserWebsitesIncludingTeamOwner(userId);
+
+ const websiteIds = websites.map(a => a.id);
+
+ const websiteEventUsage = await getEventUsage(websiteIds, startDate, endDate);
+ const eventDataUsage = await getEventDataUsage(websiteIds, startDate, endDate);
+
+ const websiteUsage = websites.map(a => ({
+ websiteId: a.id,
+ websiteName: a.name,
+ websiteEventUsage: websiteEventUsage.find(b => a.id === b.websiteId)?.count || 0,
+ eventDataUsage: eventDataUsage.find(b => a.id === b.websiteId)?.count || 0,
+ deletedAt: a.deletedAt,
+ }));
+
+ const usage = websiteUsage.reduce(
+ (acc, cv) => {
+ acc.websiteEventUsage += cv.websiteEventUsage;
+ acc.eventDataUsage += cv.eventDataUsage;
+
+ return acc;
+ },
+ { websiteEventUsage: 0, eventDataUsage: 0 },
+ );
+
+ const filteredWebsiteUsage = websiteUsage.filter(
+ a => !a.deletedAt && (a.websiteEventUsage > 0 || a.eventDataUsage > 0),
+ );
+
+ return json({
+ ...usage,
+ websites: filteredWebsiteUsage,
+ });
+}
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 000000000..77d410845
--- /dev/null
+++ b/src/app/api/users/[userId]/websites/route.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+import { unauthorized, json } from '@/lib/response';
+import { getUserWebsites } from '@/queries/prisma/website';
+import { pagingParams } from '@/lib/schema';
+import { parseRequest } from '@/lib/request';
+
+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.isAdmin && auth.user.id !== userId) {
+ return unauthorized();
+ }
+
+ const websites = await getUserWebsites(userId, query);
+
+ return json(websites);
+}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
new file mode 100644
index 000000000..320f72bd7
--- /dev/null
+++ b/src/app/api/users/route.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod';
+import { hashPassword, canCreateUser } from '@/lib/auth';
+import { ROLES } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json, badRequest } from '@/lib/response';
+import { createUser, getUserByUsername } from '@/queries';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ username: z.string().max(255),
+ password: z.string(),
+ id: z.string().uuid(),
+ 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 { username, password, role, id } = body;
+
+ const existingUser = await getUserByUsername(username, { showDeleted: true });
+
+ if (existingUser) {
+ return badRequest('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/version/route.ts b/src/app/api/version/route.ts
new file mode 100644
index 000000000..275a41184
--- /dev/null
+++ b/src/app/api/version/route.ts
@@ -0,0 +1,6 @@
+import { json } from '@/lib/response';
+import { CURRENT_VERSION } from '@/lib/constants';
+
+export async function GET() {
+ return json({ version: CURRENT_VERSION });
+}
diff --git a/src/app/api/websites/[websiteId]/active/route.ts b/src/app/api/websites/[websiteId]/active/route.ts
new file mode 100644
index 000000000..88c0fd178
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/active/route.ts
@@ -0,0 +1,25 @@
+import { canViewWebsite } from '@/lib/auth';
+import { json, unauthorized } from '@/lib/response';
+import { getActiveVisitors } from '@/queries';
+import { parseRequest } from '@/lib/request';
+
+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 result = await getActiveVisitors(websiteId);
+
+ return json(result);
+}
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 000000000..ea2d10d2c
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/daterange/route.ts
@@ -0,0 +1,25 @@
+import { canViewWebsite } from '@/lib/auth';
+import { getWebsiteDateRange } from '@/queries';
+import { json, unauthorized } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+
+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 result = await getWebsiteDateRange(websiteId);
+
+ return json(result);
+}
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 000000000..aec7b4713
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/events/route.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+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(),
+ });
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt, event } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getEventDataEvents(websiteId, {
+ startDate,
+ endDate,
+ event,
+ });
+
+ 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 000000000..60101e45c
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/fields/route.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getEventDataFields } from '@/queries';
+
+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(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getEventDataFields(websiteId, {
+ startDate,
+ endDate,
+ });
+
+ 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 000000000..fe085f74e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/properties/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getEventDataProperties } from '@/queries';
+
+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(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt, propertyName } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getEventDataProperties(websiteId, { startDate, endDate, propertyName });
+
+ 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 000000000..6928aa1e6
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/stats/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getEventDataStats } from '@/queries';
+
+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(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getEventDataStats(websiteId, { startDate, endDate });
+
+ 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 000000000..2a912439c
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts
@@ -0,0 +1,42 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getEventDataValues } from '@/queries';
+
+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(),
+ eventName: z.string().optional(),
+ propertyName: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt, eventName, propertyName } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getEventDataValues(websiteId, {
+ startDate,
+ endDate,
+ eventName,
+ 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 000000000..66eaba2c0
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { pagingParams } from '@/lib/schema';
+import { getWebsiteEvents } from '@/queries';
+
+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(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getWebsiteEvents(websiteId, { startDate, endDate }, query);
+
+ 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 000000000..da4b0d4f8
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/series/route.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+import { parseRequest, getRequestDateRange, getRequestFilters } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
+import { getEventMetrics } from '@/queries';
+
+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,
+ timezone: timezoneParam,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { timezone } = query;
+ const { startDate, endDate, unit } = await getRequestDateRange(query);
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = {
+ ...getRequestFilters(query),
+ startDate,
+ endDate,
+ timezone,
+ unit,
+ };
+
+ const data = await getEventMetrics(websiteId, filters);
+
+ return json(data);
+}
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 000000000..c09587394
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/metrics/route.ts
@@ -0,0 +1,167 @@
+import { z } from 'zod';
+import thenby from 'thenby';
+import { canViewWebsite } from '@/lib/auth';
+import {
+ SESSION_COLUMNS,
+ EVENT_COLUMNS,
+ FILTER_COLUMNS,
+ OPERATORS,
+ SEARCH_DOMAINS,
+ SOCIAL_DOMAINS,
+ EMAIL_DOMAINS,
+ SHOPPING_DOMAINS,
+ VIDEO_DOMAINS,
+ PAID_AD_PARAMS,
+} from '@/lib/constants';
+import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request';
+import { json, unauthorized, badRequest } from '@/lib/response';
+import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries';
+import { filterParams } from '@/lib/schema';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ limit: z.coerce.number().optional(),
+ offset: z.coerce.number().optional(),
+ search: z.string().optional(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { type, limit, offset, search } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { startDate, endDate } = await getRequestDateRange(query);
+ const column = FILTER_COLUMNS[type] || type;
+ const filters = {
+ ...getRequestFilters(query),
+ startDate,
+ endDate,
+ };
+
+ if (search) {
+ filters[type] = {
+ name: type,
+ column,
+ operator: OPERATORS.contains,
+ value: search,
+ };
+ }
+
+ if (SESSION_COLUMNS.includes(type)) {
+ const data = await getSessionMetrics(websiteId, type, filters, limit, offset);
+
+ if (type === 'language') {
+ const combined = {};
+
+ for (const { x, y } of data) {
+ const key = String(x).toLowerCase().split('-')[0];
+
+ if (combined[key] === undefined) {
+ combined[key] = { x: key, y };
+ } else {
+ combined[key].y += y;
+ }
+ }
+
+ return json(Object.values(combined));
+ }
+
+ return json(data);
+ }
+
+ if (EVENT_COLUMNS.includes(type)) {
+ const data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
+
+ return json(data);
+ }
+
+ if (type === 'channel') {
+ const data = await getChannelMetrics(websiteId, filters);
+
+ const channels = getChannels(data);
+
+ return json(
+ Object.keys(channels)
+ .map(key => ({ x: key, y: channels[key] }))
+ .sort(thenby.firstBy('y', -1)),
+ );
+ }
+
+ return badRequest();
+}
+
+function getChannels(data: { domain: string; query: string; visitors: number }[]) {
+ const channels = {
+ direct: 0,
+ referral: 0,
+ affiliate: 0,
+ email: 0,
+ sms: 0,
+ organicSearch: 0,
+ organicSocial: 0,
+ organicShopping: 0,
+ organicVideo: 0,
+ paidAds: 0,
+ paidSearch: 0,
+ paidSocial: 0,
+ paidShopping: 0,
+ paidVideo: 0,
+ };
+
+ const match = (value: string) => {
+ return (str: string | RegExp) => {
+ return typeof str === 'string' ? value.includes(str) : (str as RegExp).test(value);
+ };
+ };
+
+ for (const { domain, query, visitors } of data) {
+ if (!domain && !query) {
+ channels.direct += Number(visitors);
+ }
+
+ const prefix = /utm_medium=(.*cp.*|ppc|retargeting|paid.*)/.test(query) ? 'paid' : 'organic';
+
+ if (SEARCH_DOMAINS.some(match(domain)) || /utm_medium=organic/.test(query)) {
+ channels[`${prefix}Search`] += Number(visitors);
+ } else if (
+ SOCIAL_DOMAINS.some(match(domain)) ||
+ /utm_medium=(social|social-network|social-media|sm|social network|social media)/.test(query)
+ ) {
+ channels[`${prefix}Social`] += Number(visitors);
+ } else if (EMAIL_DOMAINS.some(match(domain)) || /utm_medium=(.*e[-_ ]?mail.*)/.test(query)) {
+ channels.email += Number(visitors);
+ } else if (
+ SHOPPING_DOMAINS.some(match(domain)) ||
+ /utm_campaign=(.*(([^a-df-z]|^)shop|shopping).*)/.test(query)
+ ) {
+ channels[`${prefix}Shopping`] += Number(visitors);
+ } else if (VIDEO_DOMAINS.some(match(domain)) || /utm_medium=(.*video.*)/.test(query)) {
+ channels[`${prefix}Video`] += Number(visitors);
+ } else if (PAID_AD_PARAMS.some(match(query))) {
+ channels.paidAds += Number(visitors);
+ } else if (/utm_medium=(referral|app|link)/.test(query)) {
+ channels.referral += Number(visitors);
+ } else if (/utm_medium=affiliate/.test(query)) {
+ channels.affiliate += Number(visitors);
+ } else if (/utm_(source|medium)=sms/.test(query)) {
+ channels.sms += Number(visitors);
+ }
+ }
+
+ return channels;
+}
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 000000000..e603ae9c0
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/pageviews/route.ts
@@ -0,0 +1,85 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request';
+import { unitParam, timezoneParam, filterParams } from '@/lib/schema';
+import { getCompareDate } from '@/lib/date';
+import { unauthorized, json } from '@/lib/response';
+import { getPageviewStats, getSessionStats } from '@/queries';
+
+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,
+ timezone: timezoneParam,
+ compare: z.string().optional(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { timezone, compare } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { startDate, endDate, unit } = await getRequestDateRange(query);
+
+ const filters = {
+ ...getRequestFilters(query),
+ startDate,
+ endDate,
+ timezone,
+ unit,
+ };
+
+ const [pageviews, sessions] = await Promise.all([
+ getPageviewStats(websiteId, filters),
+ getSessionStats(websiteId, filters),
+ ]);
+
+ if (compare) {
+ const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
+ compare,
+ startDate,
+ 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,
+ 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 000000000..c6941f537
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/reports/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { getWebsiteReports } from '@/queries';
+import { pagingParams } from '@/lib/schema';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...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 getWebsiteReports(websiteId, {
+ page: +page,
+ pageSize: +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 000000000..62edceeae
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/reset/route.ts
@@ -0,0 +1,25 @@
+import { canUpdateWebsite } from '@/lib/auth';
+import { resetWebsite } from '@/queries';
+import { unauthorized, ok } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+
+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 000000000..f4ea327b9
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -0,0 +1,84 @@
+import { z } from 'zod';
+import { canUpdateWebsite, canDeleteWebsite, canViewWebsite } from '@/lib/auth';
+import { SHARE_ID_REGEX } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { ok, json, unauthorized, serverError } from '@/lib/response';
+import { deleteWebsite, getWebsite, updateWebsite } from '@/queries';
+
+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(),
+ domain: z.string(),
+ shareId: z.string().regex(SHARE_ID_REGEX).nullable(),
+ });
+
+ 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.includes('Unique constraint') && e.message.includes('share_id')) {
+ return serverError(new Error('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]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts
new file mode 100644
index 000000000..a6d9e2a4a
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getSessionDataProperties } from '@/queries';
+
+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(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { startAt, endAt, propertyName } = query;
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getSessionDataProperties(websiteId, { startDate, endDate, propertyName });
+
+ 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 000000000..d950da340
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts
@@ -0,0 +1,40 @@
+import { canViewWebsite } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { getSessionDataValues } from '@/queries';
+import { z } from 'zod';
+
+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(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { startAt, endAt, propertyName } = query;
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getSessionDataValues(websiteId, {
+ startDate,
+ endDate,
+ 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 000000000..aac40c38f
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getSessionActivity } from '@/queries';
+
+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;
+ const { startAt, endAt } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getSessionActivity(websiteId, sessionId, startDate, endDate);
+
+ 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 000000000..9c389c827
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts
@@ -0,0 +1,25 @@
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getSessionData } from '@/queries';
+import { parseRequest } from '@/lib/request';
+
+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 000000000..c4621ef42
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts
@@ -0,0 +1,25 @@
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getWebsiteSession } from '@/queries';
+import { parseRequest } from '@/lib/request';
+
+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 000000000..5a14f00f7
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { pagingParams } from '@/lib/schema';
+import { getWebsiteSessions } from '@/queries';
+
+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(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getWebsiteSessions(websiteId, { startDate, endDate }, query);
+
+ 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 000000000..e8e8e6c86
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/stats/route.ts
@@ -0,0 +1,48 @@
+import { z } from 'zod';
+import { parseRequest, getRequestDateRange, getRequestFilters } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { filterParams } from '@/lib/schema';
+import { getWebsiteSessionStats } from '@/queries';
+
+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 { startDate, endDate } = await getRequestDateRange(query);
+
+ const filters = getRequestFilters(query);
+
+ const metrics = await getWebsiteSessionStats(websiteId, {
+ ...filters,
+ startDate,
+ endDate,
+ });
+
+ 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 000000000..20be378d7
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { pagingParams, timezoneParam } from '@/lib/schema';
+import { getWebsiteSessionsWeekly } from '@/queries';
+
+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,
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt, timezone } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getWebsiteSessionsWeekly(websiteId, { startDate, endDate, timezone });
+
+ 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 000000000..c146271f9
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/stats/route.ts
@@ -0,0 +1,63 @@
+import { z } from 'zod';
+import { parseRequest, getRequestDateRange, getRequestFilters } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { getCompareDate } from '@/lib/date';
+import { filterParams } from '@/lib/schema';
+import { getWebsiteStats } from '@/queries';
+
+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(),
+ compare: z.string().optional(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { compare } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { startDate, endDate } = await getRequestDateRange(query);
+ const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
+ compare,
+ startDate,
+ endDate,
+ );
+
+ const filters = getRequestFilters(query);
+
+ const metrics = await getWebsiteStats(websiteId, {
+ ...filters,
+ startDate,
+ endDate,
+ });
+
+ const prevPeriod = await getWebsiteStats(websiteId, {
+ ...filters,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ });
+
+ const stats = Object.keys(metrics[0]).reduce((obj, key) => {
+ obj[key] = {
+ value: Number(metrics[0][key]) || 0,
+ prev: Number(prevPeriod[0][key]) || 0,
+ };
+ return obj;
+ }, {});
+
+ return json(stats);
+}
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 000000000..03c0ae7f4
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/transfer/route.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from '@/lib/auth';
+import { updateWebsite } from '@/queries';
+import { parseRequest } from '@/lib/request';
+import { badRequest, unauthorized, json } from '@/lib/response';
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ userId: z.string().uuid().optional(),
+ teamId: z.string().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 000000000..ed3cfae69
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/values/route.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { getValues } from '@/queries';
+import { parseRequest, getRequestDateRange } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ search: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { type, search } = query;
+ const { startDate, endDate } = await getRequestDateRange(query);
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) {
+ return badRequest('Invalid type.');
+ }
+
+ const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
+
+ 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 000000000..b8fb2a0b8
--- /dev/null
+++ b/src/app/api/websites/route.ts
@@ -0,0 +1,59 @@
+import { z } from 'zod';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/lib/auth';
+import { json, unauthorized } from '@/lib/response';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { createWebsite, getUserWebsites } from '@/queries';
+import { pagingParams } from '@/lib/schema';
+
+export async function GET(request: Request) {
+ const schema = z.object({ ...pagingParams });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const websites = await getUserWebsites(auth.user.id, query);
+
+ return json(websites);
+}
+
+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.string().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { name, domain, shareId, teamId } = body;
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ 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);
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3c0ed43c9..f88d8169c 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -5,8 +5,8 @@ import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import 'react-basics/dist/styles.css';
-import 'styles/index.css';
-import 'styles/variables.css';
+import '@/styles/index.css';
+import '@/styles/variables.css';
export default function ({ children }) {
return (
diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx
index 3101bf486..a808c622d 100644
--- a/src/app/login/LoginForm.tsx
+++ b/src/app/login/LoginForm.tsx
@@ -9,10 +9,10 @@ import {
Icon,
} from 'react-basics';
import { useRouter } from 'next/navigation';
-import { useApi, useMessages } from 'components/hooks';
-import { setUser } from 'store/app';
-import { setClientAuthToken } from 'lib/client';
-import Logo from 'assets/logo.svg';
+import { useApi, useMessages } from '@/components/hooks';
+import { setUser } from '@/store/app';
+import { setClientAuthToken } from '@/lib/client';
+import Logo from '@/assets/logo.svg';
import styles from './LoginForm.module.css';
export function LoginForm() {
diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx
index 11d963290..d3dc481ab 100644
--- a/src/app/logout/LogoutPage.tsx
+++ b/src/app/logout/LogoutPage.tsx
@@ -1,9 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
-import { useApi } from 'components/hooks';
-import { setUser } from 'store/app';
-import { removeClientAuthToken } from 'lib/client';
+import { useApi } from '@/components/hooks';
+import { setUser } from '@/store/app';
+import { removeClientAuthToken } from '@/lib/client';
export function LogoutPage() {
const disabled = !!(process.env.disableLogin || process.env.cloudMode);
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 7a2bbb53c..c673e40f4 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,6 +1,6 @@
'use client';
import { Flexbox } from 'react-basics';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
export default function () {
const { formatMessage, labels } = useMessages();
diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx
index 3a07c12a8..e1ba9833c 100644
--- a/src/app/share/[...shareId]/Footer.tsx
+++ b/src/app/share/[...shareId]/Footer.tsx
@@ -1,4 +1,4 @@
-import { CURRENT_VERSION, HOMEPAGE_URL } from 'lib/constants';
+import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
import styles from './Footer.module.css';
export function Footer() {
diff --git a/src/app/share/[...shareId]/Header.module.css b/src/app/share/[...shareId]/Header.module.css
index d353d79a1..9fc946c78 100644
--- a/src/app/share/[...shareId]/Header.module.css
+++ b/src/app/share/[...shareId]/Header.module.css
@@ -7,10 +7,6 @@
height: 100px;
}
-.row {
- align-items: center;
-}
-
.title {
display: flex;
flex-direction: row;
@@ -32,10 +28,4 @@
.header .buttons {
flex: 1;
}
-
- .links {
- order: 2;
- margin: 20px 0;
- min-width: 100%;
- }
}
diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx
index ddfb52a55..a71a5b560 100644
--- a/src/app/share/[...shareId]/Header.tsx
+++ b/src/app/share/[...shareId]/Header.tsx
@@ -1,9 +1,9 @@
import { Icon, Text } from 'react-basics';
import Link from 'next/link';
-import LanguageButton from 'components/input/LanguageButton';
-import ThemeButton from 'components/input/ThemeButton';
-import SettingsButton from 'components/input/SettingsButton';
-import Icons from 'components/icons';
+import LanguageButton from '@/components/input/LanguageButton';
+import ThemeButton from '@/components/input/ThemeButton';
+import SettingsButton from '@/components/input/SettingsButton';
+import Icons from '@/components/icons';
import styles from './Header.module.css';
export function Header() {
diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx
index c4d9af62d..00c7ec3f2 100644
--- a/src/app/share/[...shareId]/SharePage.tsx
+++ b/src/app/share/[...shareId]/SharePage.tsx
@@ -1,11 +1,11 @@
'use client';
import WebsiteDetailsPage from '../../(main)/websites/[websiteId]/WebsiteDetailsPage';
-import { useShareToken } from 'components/hooks';
-import Page from 'components/layout/Page';
+import { useShareToken } from '@/components/hooks';
+import Page from '@/components/layout/Page';
import Header from './Header';
import Footer from './Footer';
import styles from './SharePage.module.css';
-import { WebsiteProvider } from 'app/(main)/websites/[websiteId]/WebsiteProvider';
+import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
export default function SharePage({ shareId }) {
const { shareToken, isLoading } = useShareToken(shareId);
diff --git a/src/app/share/[...shareId]/page.tsx b/src/app/share/[...shareId]/page.tsx
index c06274aa0..548082fb0 100644
--- a/src/app/share/[...shareId]/page.tsx
+++ b/src/app/share/[...shareId]/page.tsx
@@ -1,6 +1,6 @@
import SharePage from './SharePage';
-export default async function ({ params }: { params: { shareId: string } }) {
+export default async function ({ params }: { params: Promise<{ shareId: string }> }) {
const { shareId } = await params;
return