{countryNames[country] || formatMessage(labels.unknown)},
- browser: {BROWSERS[browser]},
- os: {OS_NAMES[os] || os},
- device: {formatMessage(labels[device] || labels.unknown)},
- }}
- />
- );
+ return formatMessage(messages.visitorLog, {
+ country: {countryNames[country] || formatMessage(labels.unknown)},
+ browser: {BROWSERS[browser]},
+ os: {OS_NAMES[os] || os},
+ device: {formatMessage(labels[device] || labels.unknown)},
+ });
}
};
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx
index 15b40f01..ce95bf41 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx
@@ -1,11 +1,11 @@
import { Key, useContext, useState } from 'react';
import { ButtonGroup, Button, Flexbox } from 'react-basics';
import thenby from 'thenby';
-import { percentFilter } from 'lib/filters';
-import ListTable from 'components/metrics/ListTable';
-import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
-import { useMessages } from 'components/hooks';
-import { RealtimeData } from 'lib/types';
+import { percentFilter } from '@/lib/filters';
+import ListTable from '@/components/metrics/ListTable';
+import { FILTER_PAGES, FILTER_REFERRERS } from '@/lib/constants';
+import { useMessages } from '@/components/hooks';
+import { RealtimeData } from '@/lib/types';
import { WebsiteContext } from '../WebsiteProvider';
export function RealtimeUrls({ data }: { data: RealtimeData }) {
diff --git a/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx
index 7030cc32..6edc28f9 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx
@@ -1,16 +1,16 @@
'use client';
import { firstBy } from 'thenby';
-import { Grid, GridRow } from 'components/layout/Grid';
-import Page from 'components/layout/Page';
-import RealtimeChart from 'components/metrics/RealtimeChart';
-import WorldMap from 'components/metrics/WorldMap';
-import { useRealtime } from 'components/hooks';
+import { Grid, GridRow } from '@/components/layout/Grid';
+import Page from '@/components/layout/Page';
+import RealtimeChart from '@/components/metrics/RealtimeChart';
+import WorldMap from '@/components/metrics/WorldMap';
+import { useRealtime } from '@/components/hooks';
import RealtimeLog from './RealtimeLog';
import RealtimeHeader from './RealtimeHeader';
import RealtimeUrls from './RealtimeUrls';
import RealtimeCountries from './RealtimeCountries';
import WebsiteHeader from '../WebsiteHeader';
-import { percentFilter } from 'lib/filters';
+import { percentFilter } from '@/lib/filters';
export function WebsiteRealtimePage({ websiteId }) {
const { data, isLoading, error } = useRealtime(websiteId);
diff --git a/src/app/(main)/websites/[websiteId]/reports/WebsiteReportsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/WebsiteReportsPage.tsx
index 051f6ed3..e61aacb1 100644
--- a/src/app/(main)/websites/[websiteId]/reports/WebsiteReportsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/reports/WebsiteReportsPage.tsx
@@ -1,9 +1,9 @@
'use client';
import Link from 'next/link';
import { Button, Flexbox, Icon, Icons, Text } from 'react-basics';
-import { useMessages, useTeamUrl } from 'components/hooks';
+import { useMessages, useTeamUrl } from '@/components/hooks';
import WebsiteHeader from '../WebsiteHeader';
-import ReportsDataTable from 'app/(main)/reports/ReportsDataTable';
+import ReportsDataTable from '@/app/(main)/reports/ReportsDataTable';
export function WebsiteReportsPage({ websiteId }) {
const { formatMessage, labels } = useMessages();
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
index 49b63e74..a0b47bc9 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
@@ -1,9 +1,9 @@
import { GridColumn, GridTable } from 'react-basics';
-import { useSessionDataProperties, useSessionDataValues, useMessages } from 'components/hooks';
-import { LoadingPanel } from 'components/common/LoadingPanel';
-import PieChart from 'components/charts/PieChart';
+import { useSessionDataProperties, useSessionDataValues, useMessages } from '@/components/hooks';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import PieChart from '@/components/charts/PieChart';
import { useState } from 'react';
-import { CHART_COLORS } from 'lib/constants';
+import { CHART_COLORS } from '@/lib/constants';
import styles from './SessionProperties.module.css';
export function SessionProperties({ websiteId }: { websiteId: string }) {
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
index 788d0066..56e0df62 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
@@ -1,6 +1,6 @@
-import { useWebsiteSessions } from 'components/hooks';
+import { useWebsiteSessions } from '@/components/hooks';
import SessionsTable from './SessionsTable';
-import DataTable from 'components/common/DataTable';
+import DataTable from '@/components/common/DataTable';
import { ReactNode } from 'react';
export default function SessionsDataTable({
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
index 803e7a06..62d60de8 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
@@ -1,9 +1,9 @@
-import { useMessages } from 'components/hooks';
-import useWebsiteSessionStats from 'components/hooks/queries/useWebsiteSessionStats';
-import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
-import MetricCard from 'components/metrics/MetricCard';
-import MetricsBar from 'components/metrics/MetricsBar';
-import { formatLongNumber } from 'lib/format';
+import { useMessages } from '@/components/hooks';
+import useWebsiteSessionStats from '@/components/hooks/queries/useWebsiteSessionStats';
+import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
+import MetricCard from '@/components/metrics/MetricCard';
+import MetricsBar from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
import { Flexbox } from 'react-basics';
export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
index 30fd193db..2ee044db 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
@@ -3,11 +3,11 @@ import WebsiteHeader from '../WebsiteHeader';
import SessionsDataTable from './SessionsDataTable';
import SessionsMetricsBar from './SessionsMetricsBar';
import SessionProperties from './SessionProperties';
-import WorldMap from 'components/metrics/WorldMap';
-import { GridRow } from 'components/layout/Grid';
+import WorldMap from '@/components/metrics/WorldMap';
+import { GridRow } from '@/components/layout/Grid';
import { Item, Tabs } from 'react-basics';
import { useState } from 'react';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
import SessionsWeekly from './SessionsWeekly';
export function SessionsPage({ websiteId }) {
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
index 3fea4836..ddb3ed65 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
@@ -1,9 +1,9 @@
import Link from 'next/link';
import { GridColumn, GridTable } from 'react-basics';
-import { useFormat, useMessages, useTimezone } from 'components/hooks';
-import Avatar from 'components/common/Avatar';
+import { useFormat, useMessages, useTimezone } from '@/components/hooks';
+import Avatar from '@/components/common/Avatar';
import styles from './SessionsTable.module.css';
-import TypeIcon from 'components/common/TypeIcon';
+import TypeIcon from '@/components/common/TypeIcon';
export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean }) {
const { formatTimezoneDate } = useTimezone();
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx
index 3e15ddfa..6082f0e2 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx
@@ -1,7 +1,7 @@
import { format, startOfDay, addHours } from 'date-fns';
-import { useLocale, useMessages, useWebsiteSessionsWeekly } from 'components/hooks';
-import { LoadingPanel } from 'components/common/LoadingPanel';
-import { getDayOfWeekAsDate } from 'lib/date';
+import { useLocale, useMessages, useWebsiteSessionsWeekly } from '@/components/hooks';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { getDayOfWeekAsDate } from '@/lib/date';
import styles from './SessionsWeekly.module.css';
import classNames from 'classnames';
import { TooltipPopup } from 'react-basics';
@@ -54,10 +54,10 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) {
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
- {day?.map((hour: number) => {
+ {day?.map((hour: number, n) => {
const pct = hour / max;
return (
-
+
{hour > 0 && (
+
{showHeader && (
{formatTimezoneDate(createdAt, 'EEEE, PPP')}
)}
@@ -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 39b6afd1..56d4a0d9 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 d6a07edc..9ccf275f 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 6f9a8f3d..889eb972 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 ea606582..eb385e9b 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 bbc10a35..66884c2f 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 00000000..bb892f01
--- /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 00000000..2185e03e
--- /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 00000000..3f35ea49
--- /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 00000000..0b48fe83
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+import { checkPassword } from '@/lib/auth';
+import { createSecureToken } from '@/lib/jwt';
+import { redisEnabled } from '@umami/redis-client';
+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();
+ }
+
+ if (redisEnabled) {
+ const token = await saveAuth({ userId: user.id });
+
+ return json({ token, user });
+ }
+
+ const token = createSecureToken({ userId: user.id }, secret());
+ const { id, role, createdAt } = user;
+
+ 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 00000000..22bb3091
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,14 @@
+import { getClient, redisEnabled } from '@umami/redis-client';
+import { ok } from '@/lib/response';
+
+export async function POST(request: Request) {
+ if (redisEnabled) {
+ const redis = getClient();
+
+ const token = request.headers.get('authorization')?.split(' ')?.[1];
+
+ await redis.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 00000000..4a713424
--- /dev/null
+++ b/src/app/api/auth/sso/route.ts
@@ -0,0 +1,18 @@
+import { redisEnabled } from '@umami/redis-client';
+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 (redisEnabled) {
+ 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 00000000..4d98b554
--- /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 00000000..91463089
--- /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 00000000..69bef49b
--- /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 00000000..59a32552
--- /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 00000000..2ea6575e
--- /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 00000000..a8df856a
--- /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 00000000..7f9c1a9a
--- /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 00000000..ba90ee08
--- /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 00000000..471ae709
--- /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.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 00000000..5a2f6bd0
--- /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 00000000..b3569cba
--- /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 00000000..b8a0a0a4
--- /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.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 00000000..83220bb4
--- /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 00000000..f8f4041f
--- /dev/null
+++ b/src/app/api/reports/revenue/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 { 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({
+ ...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 00000000..e50c57bc
--- /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 00000000..38e88a6d
--- /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 ecd83fcb..54cee565 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 00000000..415b5a8e
--- /dev/null
+++ b/src/app/api/send/route.ts
@@ -0,0 +1,198 @@
+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 } from '@/lib/constants';
+import { createSession, saveEvent, saveSessionData } from '@/queries';
+
+export async function POST(request: Request) {
+ // Bot check
+ if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
+ return json({ beep: 'boop' });
+ }
+
+ const schema = z.object({
+ type: z.enum(['event', 'identify']),
+ payload: z.object({
+ website: z.string().uuid(),
+ data: z.object({}).passthrough().optional(),
+ hostname: z.string().max(100).optional(),
+ language: z.string().max(35).optional(),
+ referrer: z.string().optional(),
+ screen: z.string().max(11).optional(),
+ title: z.string().optional(),
+ url: z.string().optional(),
+ name: z.string().max(50).optional(),
+ tag: z.string().max(50).optional(),
+ ip: z.string().ip().optional(),
+ userAgent: z.string().optional(),
+ }),
+ });
+
+ 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 (!cache?.sessionId) {
+ const session = await fetchSession(websiteId, sessionId);
+
+ // Create a session if not found
+ if (!session && !clickhouse.enabled) {
+ 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 ? `http://${hostname}` : 'http://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, websiteId, sessionId, visitId, iat });
+}
diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts
new file mode 100644
index 00000000..e387938d
--- /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 00000000..f7f4b331
--- /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 00000000..cadcd8b0
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts
@@ -0,0 +1,78 @@
+import { z } from 'zod';
+import { unauthorized, json, badRequest, ok } from '@/lib/response';
+import { canDeleteTeam, canUpdateTeam } from '@/lib/auth';
+import { parseRequest } from '@/lib/request';
+import { deleteTeam, getTeamUser, updateTeamUser } from '@/queries';
+
+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 }> },
+) {
+ 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/route.ts b/src/app/api/teams/[teamId]/users/route.ts
new file mode 100644
index 00000000..5ec9435f
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/route.ts
@@ -0,0 +1,79 @@
+import { z } from 'zod';
+import { unauthorized, json, badRequest } from '@/lib/response';
+import { canAddUserToTeam, canUpdateTeam } 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 canUpdateTeam(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; userId: string }> },
+) {
+ const schema = z.object({
+ 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 00000000..f69ab465
--- /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 00000000..3464054c
--- /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 00000000..d319d87b
--- /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 00000000..abb3331d
--- /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 00000000..ff659525
--- /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 00000000..e6ff217d
--- /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 00000000..77d41084
--- /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 00000000..320f72bd
--- /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 00000000..275a4118
--- /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 00000000..88c0fd17
--- /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 00000000..ea2d10d2
--- /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 00000000..aec7b471
--- /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 00000000..60101e45
--- /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 00000000..fe085f74
--- /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 00000000..6928aa1e
--- /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 00000000..2a912439
--- /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 00000000..66eaba2c
--- /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 00000000..da4b0d4f
--- /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 00000000..70ed9f90
--- /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 += 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`] += 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`] += visitors;
+ } else if (EMAIL_DOMAINS.some(match(domain)) || /utm_medium=(.*e[-_ ]?mail.*)/.test(query)) {
+ channels.email += visitors;
+ } else if (
+ SHOPPING_DOMAINS.some(match(domain)) ||
+ /utm_campaign=(.*(([^a-df-z]|^)shop|shopping).*)/.test(query)
+ ) {
+ channels[`${prefix}Shopping`] += visitors;
+ } else if (VIDEO_DOMAINS.some(match(domain)) || /utm_medium=(.*video.*)/.test(query)) {
+ channels[`${prefix}Video`] += visitors;
+ } else if (PAID_AD_PARAMS.some(match(query))) {
+ channels.paidAds += visitors;
+ } else if (/utm_medium=(referral|app|link)/.test(query)) {
+ channels.referral += visitors;
+ } else if (/utm_medium=affiliate/.test(query)) {
+ channels.affiliate += visitors;
+ } else if (/utm_(source|medium)=sms/.test(query)) {
+ channels.sms += 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 00000000..e603ae9c
--- /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 00000000..c6941f53
--- /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 00000000..62edceea
--- /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 00000000..f4ea327b
--- /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 00000000..a6d9e2a4
--- /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 00000000..93e91775
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts
@@ -0,0 +1,40 @@
+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(),
+ propertyName: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { startAt, endAt, event } = 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 getEventDataEvents(websiteId, {
+ startDate,
+ endDate,
+ event,
+ });
+
+ 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 00000000..aac40c38
--- /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 00000000..9c389c82
--- /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 00000000..c4621ef4
--- /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 00000000..5a14f00f
--- /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 00000000..e8e8e6c8
--- /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 00000000..20be378d
--- /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 00000000..c146271f
--- /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 00000000..03c0ae7f
--- /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 00000000..ed3cfae6
--- /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 00000000..b8fb2a0b
--- /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 3c0ed43c..f88d8169 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 3101bf48..a808c622 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 11d96329..d3dc481a 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 7a2bbb53..c673e40f 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 3a07c12a..e1ba9833 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 d353d79a..04478199 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;
diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx
index ddfb52a5..a71a5b56 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 c4d9af62..00c7ec3f 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/sso/SSOPage.tsx b/src/app/sso/SSOPage.tsx
index e577767a..eb7c0f0a 100644
--- a/src/app/sso/SSOPage.tsx
+++ b/src/app/sso/SSOPage.tsx
@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { Loading } from 'react-basics';
import { useRouter, useSearchParams } from 'next/navigation';
-import { setClientAuthToken } from 'lib/client';
+import { setClientAuthToken } from '@/lib/client';
export default function SSOPage() {
const router = useRouter();
diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx
index 7c16730e..f6a6e5e0 100644
--- a/src/components/charts/BarChart.tsx
+++ b/src/components/charts/BarChart.tsx
@@ -1,7 +1,7 @@
-import BarChartTooltip from 'components/charts/BarChartTooltip';
-import Chart, { ChartProps } from 'components/charts/Chart';
-import { useTheme } from 'components/hooks';
-import { renderNumberLabels } from 'lib/charts';
+import BarChartTooltip from '@/components/charts/BarChartTooltip';
+import Chart, { ChartProps } from '@/components/charts/Chart';
+import { useTheme } from '@/components/hooks';
+import { renderNumberLabels } from '@/lib/charts';
import { useMemo, useState } from 'react';
export interface BarChartProps extends ChartProps {
diff --git a/src/components/charts/BarChartTooltip.tsx b/src/components/charts/BarChartTooltip.tsx
index 201c6e4c..af31c874 100644
--- a/src/components/charts/BarChartTooltip.tsx
+++ b/src/components/charts/BarChartTooltip.tsx
@@ -1,6 +1,6 @@
-import { useLocale } from 'components/hooks';
-import { formatDate } from 'lib/date';
-import { formatLongCurrency, formatLongNumber } from 'lib/format';
+import { useLocale } from '@/components/hooks';
+import { formatDate } from '@/lib/date';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { Flexbox, StatusLight } from 'react-basics';
const formats = {
diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx
index 956e260c..dfe67f3a 100644
--- a/src/components/charts/BubbleChart.tsx
+++ b/src/components/charts/BubbleChart.tsx
@@ -1,7 +1,7 @@
-import { Chart, ChartProps } from 'components/charts/Chart';
+import { Chart, ChartProps } from '@/components/charts/Chart';
import { useState } from 'react';
import { StatusLight } from 'react-basics';
-import { formatLongNumber } from 'lib/format';
+import { formatLongNumber } from '@/lib/format';
export interface BubbleChartProps extends ChartProps {
type?: 'bubble';
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx
index a4badbce..dde01eb4 100644
--- a/src/components/charts/Chart.tsx
+++ b/src/components/charts/Chart.tsx
@@ -2,9 +2,9 @@ import { useState, useRef, useEffect, useMemo, ReactNode } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto';
-import HoverTooltip from 'components/common/HoverTooltip';
-import Legend from 'components/metrics/Legend';
-import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
+import HoverTooltip from '@/components/common/HoverTooltip';
+import Legend from '@/components/metrics/Legend';
+import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
import styles from './Chart.module.css';
export interface ChartProps {
diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx
index 57d676ca..a98b9730 100644
--- a/src/components/charts/PieChart.tsx
+++ b/src/components/charts/PieChart.tsx
@@ -1,7 +1,7 @@
-import { Chart, ChartProps } from 'components/charts/Chart';
+import { Chart, ChartProps } from '@/components/charts/Chart';
import { useState } from 'react';
import { StatusLight } from 'react-basics';
-import { formatLongNumber } from 'lib/format';
+import { formatLongNumber } from '@/lib/format';
export interface PieChartProps extends ChartProps {
type?: 'doughnut' | 'pie';
diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx
index 2e82b078..d0cae247 100644
--- a/src/components/common/Avatar.tsx
+++ b/src/components/common/Avatar.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { createAvatar } from '@dicebear/core';
import { lorelei } from '@dicebear/collection';
-import { getColor, getPastel } from 'lib/colors';
+import { getColor, getPastel } from '@/lib/colors';
const lib = lorelei;
diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx
index 26b4ff24..8b617ab5 100644
--- a/src/components/common/ConfirmationForm.tsx
+++ b/src/components/common/ConfirmationForm.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { Button, LoadingButton, Form, FormButtons } from 'react-basics';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
export interface ConfirmationFormProps {
message: ReactNode;
diff --git a/src/components/common/DataTable.tsx b/src/components/common/DataTable.tsx
index d2094329..b19ddf91 100644
--- a/src/components/common/DataTable.tsx
+++ b/src/components/common/DataTable.tsx
@@ -1,12 +1,12 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Loading, SearchField } from 'react-basics';
-import { useMessages, useNavigation } from 'components/hooks';
-import Empty from 'components/common/Empty';
-import Pager from 'components/common/Pager';
-import { PagedQueryResult } from 'lib/types';
+import { useMessages, useNavigation } from '@/components/hooks';
+import Empty from '@/components/common/Empty';
+import Pager from '@/components/common/Pager';
+import { PagedQueryResult } from '@/lib/types';
import styles from './DataTable.module.css';
-import { LoadingPanel } from 'components/common/LoadingPanel';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
const DEFAULT_SEARCH_DELAY = 600;
@@ -37,26 +37,26 @@ export function DataTable({
query: { error, isLoading, isFetched },
} = queryResult || {};
const { page, pageSize, count, data } = result || {};
- const { query } = params || {};
+ const { search } = params || {};
const hasData = Boolean(!isLoading && data?.length);
- const noResults = Boolean(query && !hasData);
+ const noResults = Boolean(search && !hasData);
const { router, renderUrl } = useNavigation();
- const handleSearch = (query: string) => {
- setParams({ ...params, query, page: params.page ? page : 1 });
+ const handleSearch = (search: string) => {
+ setParams({ ...params, search, page: params.page ? page : 1 });
};
const handlePageChange = (page: number) => {
- setParams({ ...params, query, page });
+ setParams({ ...params, search, page });
router.push(renderUrl({ page }));
};
return (
<>
- {allowSearch && (hasData || query) && (
+ {allowSearch && (hasData || search) && (
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
{isLoading && }
- {!isLoading && !hasData && !query && (renderEmpty ? renderEmpty() : )}
+ {!isLoading && !hasData && !search && (renderEmpty ? renderEmpty() : )}
{!isLoading && noResults && }
{allowPaging && hasData && (
diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx
index 8e7d2d00..cf6d11cc 100644
--- a/src/components/common/Empty.tsx
+++ b/src/components/common/Empty.tsx
@@ -1,5 +1,5 @@
import classNames from 'classnames';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
import styles from './Empty.module.css';
export interface EmptyProps {
diff --git a/src/components/common/EmptyPlaceholder.tsx b/src/components/common/EmptyPlaceholder.tsx
index 640e45d5..2fd606cd 100644
--- a/src/components/common/EmptyPlaceholder.tsx
+++ b/src/components/common/EmptyPlaceholder.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { Icon, Text, Flexbox } from 'react-basics';
-import Logo from 'assets/logo.svg';
+import Logo from '@/assets/logo.svg';
export interface EmptyPlaceholderProps {
message?: string;
diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx
index 9669580f..b9521bb4 100644
--- a/src/components/common/ErrorBoundary.tsx
+++ b/src/components/common/ErrorBoundary.tsx
@@ -1,7 +1,7 @@
import { ErrorInfo, ReactNode } from 'react';
import { ErrorBoundary as Boundary } from 'react-error-boundary';
import { Button } from 'react-basics';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
import styles from './ErrorBoundary.module.css';
const logError = (error: Error, info: ErrorInfo) => {
diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx
index 7ed8662a..bf3eefb1 100644
--- a/src/components/common/ErrorMessage.tsx
+++ b/src/components/common/ErrorMessage.tsx
@@ -1,6 +1,6 @@
import { Icon, Icons, Text } from 'react-basics';
import styles from './ErrorMessage.module.css';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
export function ErrorMessage() {
const { formatMessage, messages } = useMessages();
diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx
index 47c65aab..ea3f31aa 100644
--- a/src/components/common/Favicon.tsx
+++ b/src/components/common/Favicon.tsx
@@ -1,3 +1,5 @@
+import { GROUPED_DOMAINS } from '@/lib/constants';
+
function getHostName(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
@@ -9,16 +11,11 @@ export function Favicon({ domain, ...props }) {
}
const hostName = domain ? getHostName(domain) : null;
+ const src = hostName
+ ? `https://icons.duckduckgo.com/ip3/${GROUPED_DOMAINS[hostName]?.domain || hostName}.ico`
+ : null;
- return hostName ? (
-
- ) : null;
+ return hostName ?
: null;
}
export default Favicon;
diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx
index ef278ed2..9d726b58 100644
--- a/src/components/common/FilterLink.tsx
+++ b/src/components/common/FilterLink.tsx
@@ -1,6 +1,5 @@
import classNames from 'classnames';
-import { useMessages, useNavigation } from 'components/hooks';
-import { safeDecodeURIComponent } from 'next-basics';
+import { useMessages, useNavigation } from '@/components/hooks';
import Link from 'next/link';
import { ReactNode } from 'react';
import { Icon, Icons } from 'react-basics';
@@ -39,7 +38,7 @@ export function FilterLink({
{!value && `(${label || formatMessage(labels.unknown)})`}
{value && (
- {safeDecodeURIComponent(label || value)}
+ {label || value}
)}
{externalUrl && (
diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx
index 83d95151..3aa2a76a 100644
--- a/src/components/common/LinkButton.tsx
+++ b/src/components/common/LinkButton.tsx
@@ -1,8 +1,8 @@
+import { ReactNode } from 'react';
import classNames from 'classnames';
import Link from 'next/link';
-import { useLocale } from 'components/hooks';
+import { useLocale } from '@/components/hooks';
import styles from './LinkButton.module.css';
-import { ReactNode } from 'react';
export interface LinkButtonProps {
href: string;
diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx
index 36de9365..4d27618a 100644
--- a/src/components/common/LoadingPanel.tsx
+++ b/src/components/common/LoadingPanel.tsx
@@ -1,8 +1,8 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Loading } from 'react-basics';
-import ErrorMessage from 'components/common/ErrorMessage';
-import Empty from 'components/common/Empty';
+import ErrorMessage from '@/components/common/ErrorMessage';
+import Empty from '@/components/common/Empty';
import styles from './LoadingPanel.module.css';
export function LoadingPanel({
diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx
index 3e0a8033..b33d2236 100644
--- a/src/components/common/Pager.tsx
+++ b/src/components/common/Pager.tsx
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { Button, Icon, Icons } from 'react-basics';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
import styles from './Pager.module.css';
export interface PagerProps {
diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx
index 2dfb2dff..baf5949f 100644
--- a/src/components/common/TypeConfirmationForm.tsx
+++ b/src/components/common/TypeConfirmationForm.tsx
@@ -7,7 +7,7 @@ import {
TextField,
SubmitButton,
} from 'react-basics';
-import { useMessages } from 'components/hooks';
+import { useMessages } from '@/components/hooks';
export function TypeConfirmationForm({
confirmationValue,
@@ -26,7 +26,7 @@ export function TypeConfirmationForm({
onConfirm?: () => void;
onClose?: () => void;
}) {
- const { formatMessage, labels, messages, FormattedMessage } = useMessages();
+ const { formatMessage, labels, messages } = useMessages();
if (!confirmationValue) {
return null;
@@ -35,10 +35,7 @@ export function TypeConfirmationForm({
return (