diff --git a/next-env.d.ts b/next-env.d.ts index 3cd7048e..1b3be084 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.js b/next.config.js index e4e55ab7..7a65c472 100644 --- a/next.config.js +++ b/next.config.js @@ -8,6 +8,7 @@ const basePath = process.env.BASE_PATH; const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT; const cloudMode = process.env.CLOUD_MODE; const cloudUrl = process.env.CLOUD_URL; +const corsMaxAge = process.env.CORS_MAX_AGE; const defaultLocale = process.env.DEFAULT_LOCALE; const disableLogin = process.env.DISABLE_LOGIN; const disableUI = process.env.DISABLE_UI; @@ -59,6 +60,15 @@ const trackerHeaders = [ ]; const headers = [ + { + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Headers', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET, DELETE, POST, PUT' }, + { key: 'Access-Control-Max-Age', value: corsMaxAge || '86400' }, + ], + }, { source: '/:path*', headers: defaultHeaders, diff --git a/package.json b/package.json index 80c9446e..4fecc230 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "url": "https://github.com/umami-software/umami.git" }, "scripts": { - "dev": "next dev -p 3000", + "dev": "next dev -p 3000 --turbo", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "start": "next start", "build-docker": "npm-run-all build-db build-tracker build-geo build-app", @@ -106,7 +106,7 @@ "npm-run-all": "^4.1.5", "prisma": "6.1.0", "react": "^19.0.0", - "react-basics": "^0.125.0", + "react-basics": "^0.126.0", "react-dom": "^19.0.0", "react-error-boundary": "^4.0.4", "react-intl": "^6.5.5", @@ -118,7 +118,7 @@ "serialize-error": "^12.0.0", "thenby": "^1.3.4", "uuid": "^9.0.0", - "yup": "^0.32.11", + "zod": "^3.24.1", "zustand": "^4.5.5" }, "devDependencies": { diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx index efb38043..aca94bc2 100644 --- a/src/app/(main)/App.tsx +++ b/src/app/(main)/App.tsx @@ -22,6 +22,10 @@ export function App({ children }) { return null; } + if (config.uiDisabled) { + return null; + } + return ( <> {children} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index ba221990..8e47d7ee 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -4,11 +4,7 @@ import NavBar from './NavBar'; import Page from 'components/layout/Page'; import styles from './layout.module.css'; -export default function ({ children }) { - if (process.env.DISABLE_UI) { - return null; - } - +export default async function ({ children }) { return (
diff --git a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx index a1417780..bfe4ff5f 100644 --- a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx +++ b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx @@ -226,7 +226,7 @@ const ResultsMenu = ({ values, type, isLoading, onSelect }) => { return ( - {values?.map((value: any) => { + {values?.map(({ value }) => { return {formatValue(value, type)}; })} diff --git a/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx b/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx index e5673346..465492b2 100644 --- a/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx +++ b/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx @@ -35,7 +35,11 @@ export function ShareUrl({ hostUrl, onSave }: { hostUrl?: string; onSave?: () => }; const handleCheck = (checked: boolean) => { - const data = { shareId: checked ? generateId() : null }; + const data = { + name: website.name, + domain: website.domain, + shareId: checked ? generateId() : null, + }; mutate(data, { onSuccess: async () => { touch(`website:${website.id}`); @@ -47,7 +51,7 @@ export function ShareUrl({ hostUrl, onSave }: { hostUrl?: string; onSave?: () => const handleSave = () => { mutate( - { shareId: id }, + { name: website.name, domain: website.domain, shareId: id }, { onSuccess: async () => { touch(`website:${website.id}`); diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx index 3e15ddfa..4280b7e9 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx @@ -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/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..5ed4a8ff --- /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..014ef8d5 --- /dev/null +++ b/src/app/api/admin/websites/route.ts @@ -0,0 +1,87 @@ +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({ + ...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..42d71fcf --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { checkPassword, createSecureToken } from 'next-basics'; +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..ce7ce7f8 --- /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..e06e403c --- /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..db62a339 --- /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..39af3d0e --- /dev/null +++ b/src/app/api/me/password/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { parseRequest } from 'lib/request'; +import { json, badRequest } from 'lib/response'; +import { getUser, updateUser } from 'queries/prisma/user'; +import { checkPassword, hashPassword } from 'next-basics'; + +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..60a14271 --- /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..0624e94f --- /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..725ca94b --- /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..b575ac12 --- /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..0d7c0845 --- /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..23a05014 --- /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..7aceabc8 --- /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/analytics/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..a7ed1c15 --- /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..f5121fdc --- /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..8e854fa9 --- /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..abece8a1 --- /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/analytics/reports/getRevenue'; +import { getRevenueValues } from 'queries/analytics/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..19c175bd --- /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..2412134d --- /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/send/route.ts b/src/app/api/send/route.ts new file mode 100644 index 00000000..0db93e85 --- /dev/null +++ b/src/app/api/send/route.ts @@ -0,0 +1,192 @@ +import { z } from 'zod'; +import { isbot } from 'isbot'; +import { createToken, parseToken, safeDecodeURI } from 'next-basics'; +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 { createSession, saveEvent, saveSessionData } from 'queries'; +import { COLLECTION_TYPE } from 'lib/constants'; + +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', 'identity']), + 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) { + // eslint-disable-next-line prefer-const + let [urlPath, urlQuery] = safeDecodeURI(url)?.split('?') || []; + let [referrerPath, referrerQuery] = safeDecodeURI(referrer)?.split('?') || []; + let referrerDomain = ''; + + if (!urlPath) { + urlPath = '/'; + } + + if (/^[\w-]+:\/\/\w+/.test(referrerPath)) { + const refUrl = new URL(referrer); + referrerPath = refUrl.pathname; + referrerQuery = refUrl.search.substring(1); + referrerDomain = refUrl.hostname.replace(/www\./, ''); + } + + if (process.env.REMOVE_TRAILING_SLASH) { + urlPath = urlPath.replace(/(.+)\/$/, '$1'); + } + + await saveEvent({ + websiteId, + sessionId, + visitId, + urlPath, + urlQuery, + referrerPath, + referrerQuery, + referrerDomain, + pageTitle: title, + eventName: name, + eventData: data, + hostname, + 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 }); +} diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts new file mode 100644 index 00000000..f5c5ab5a --- /dev/null +++ b/src/app/api/share/[shareId]/route.ts @@ -0,0 +1,19 @@ +import { json, notFound } from 'lib/response'; +import { getSharedWebsite } from 'queries'; +import { createToken } from 'next-basics'; +import { secret } from 'lib/crypto'; + +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..0c5db380 --- /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..9276c194 --- /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..a69a6b85 --- /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..9f800e0e --- /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..76d4a83f --- /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..2eb0c8d8 --- /dev/null +++ b/src/app/api/teams/route.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { getRandomChars } from 'next-basics'; +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..0955fc7c --- /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 'next-basics'; +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..329e7413 --- /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..72510bd3 --- /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/analytics/events/getEventUsage'; +import { getEventDataUsage } from 'queries/analytics/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..22aced51 --- /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..8f9e5723 --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { hashPassword } from 'next-basics'; +import { 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..605d2583 --- /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..9e25cea6 --- /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..75423454 --- /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..4937482a --- /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/analytics/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..e95998ed --- /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..25f915b1 --- /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..a8093e61 --- /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..7734d920 --- /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..71046b3c --- /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..413988af --- /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(request), + 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..44312429 --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/route.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants'; +import { getRequestFilters, getRequestDateRange, parseRequest } from 'lib/request'; +import { json, unauthorized, badRequest } from 'lib/response'; +import { getPageviewMetrics, getSessionMetrics } 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); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/pageviews/route.ts b/src/app/api/websites/[websiteId]/pageviews/route.ts new file mode 100644 index 00000000..020e139b --- /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..44f62625 --- /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..8f2df289 --- /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..b72a5e1d --- /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..5729ddbd --- /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..f14b1916 --- /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/analytics/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..ccd05d4d --- /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..a95a0233 --- /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..0a968eb2 --- /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..da8d9d09 --- /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..016e9cf4 --- /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(request); + + 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..b57bb95c --- /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..55dc4e3e --- /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..d3e497cd --- /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..b81bdcc8 --- /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..dfc48cee --- /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/components/common/DataTable.tsx b/src/components/common/DataTable.tsx index d2094329..f3b144a6 100644 --- a/src/components/common/DataTable.tsx +++ b/src/components/common/DataTable.tsx @@ -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/hooks/queries/useConfig.ts b/src/components/hooks/queries/useConfig.ts index f6293a44..f4e911a0 100644 --- a/src/components/hooks/queries/useConfig.ts +++ b/src/components/hooks/queries/useConfig.ts @@ -1,23 +1,16 @@ import { useEffect } from 'react'; import useStore, { setConfig } from 'store/app'; -import { useApi } from '../useApi'; - -let loading = false; +import { getConfig } from 'app/actions/getConfig'; export function useConfig() { const { config } = useStore(); - const { get } = useApi(); - const configUrl = process.env.configUrl; async function loadConfig() { - const data = await get(configUrl); - loading = false; - setConfig(data); + setConfig(await getConfig()); } useEffect(() => { - if (!config && !loading && configUrl) { - loading = true; + if (!config) { loadConfig(); } }, []); diff --git a/src/components/hooks/queries/useWebsitePageviews.ts b/src/components/hooks/queries/useWebsitePageviews.ts index 42fb527e..43c51745 100644 --- a/src/components/hooks/queries/useWebsitePageviews.ts +++ b/src/components/hooks/queries/useWebsitePageviews.ts @@ -1,6 +1,6 @@ import { UseQueryOptions } from '@tanstack/react-query'; import { useApi } from '../useApi'; -import { useFilterParams } from '..//useFilterParams'; +import { useFilterParams } from '../useFilterParams'; export function useWebsitePageviews( websiteId: string, diff --git a/src/components/hooks/usePagedQuery.ts b/src/components/hooks/usePagedQuery.ts index 19471432..a4cc68a6 100644 --- a/src/components/hooks/usePagedQuery.ts +++ b/src/components/hooks/usePagedQuery.ts @@ -11,8 +11,8 @@ export function usePagedQuery({ }: Omit & { queryFn: (params?: object) => any }): PagedQueryResult { const { query: queryParams } = useNavigation(); const [params, setParams] = useState({ - query: '', - page: +queryParams.page || 1, + search: '', + page: queryParams.page || '1', }); const { useQuery } = useApi(); diff --git a/src/components/metrics/ActiveUsers.module.css b/src/components/metrics/ActiveUsers.module.css index 5d0a4c7d..4a984725 100644 --- a/src/components/metrics/ActiveUsers.module.css +++ b/src/components/metrics/ActiveUsers.module.css @@ -10,8 +10,3 @@ font-size: var(--font-size-md); font-weight: 400; } - -.value { - font-weight: 600; - margin-inline-end: 4px; -} diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx index 05d0fc1d..966b91d9 100644 --- a/src/components/metrics/ActiveUsers.tsx +++ b/src/components/metrics/ActiveUsers.tsx @@ -24,7 +24,7 @@ export function ActiveUsers({ const count = useMemo(() => { if (websiteId) { - return data?.x || 0; + return data?.visitors || 0; } return value !== undefined ? value : 0; diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts index 14c67dde..1cb558ad 100644 --- a/src/lib/__tests__/detect.test.ts +++ b/src/lib/__tests__/detect.test.ts @@ -6,17 +6,17 @@ const IP = '127.0.0.1'; test('getIpAddress: Custom header', () => { process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; - expect(detect.getIpAddress({ headers: { 'x-custom-ip-header': IP } } as any)).toEqual(IP); + expect(detect.getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP); }); test('getIpAddress: CloudFlare header', () => { - expect(detect.getIpAddress({ headers: { 'cf-connecting-ip': IP } } as any)).toEqual(IP); + expect(detect.getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); }); test('getIpAddress: Standard header', () => { - expect(detect.getIpAddress({ headers: { 'x-forwarded-for': IP } } as any)).toEqual(IP); + expect(detect.getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); }); test('getIpAddress: No header', () => { - expect(detect.getIpAddress({ headers: {} } as any)).toEqual(null); + expect(detect.getIpAddress(new Headers())).toEqual(null); }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7b8ac823..34ab49b9 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,16 +1,64 @@ import { Report } from '@prisma/client'; -import { getClient } from '@umami/redis-client'; +import { getClient, redisEnabled } from '@umami/redis-client'; import debug from 'debug'; -import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants'; +import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from 'lib/constants'; import { secret } from 'lib/crypto'; import { NextApiRequest } from 'next'; -import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics'; -import { getTeamUser, getWebsite } from 'queries'; +import { + createSecureToken, + ensureArray, + getRandomChars, + parseSecureToken, + parseToken, +} from 'next-basics'; +import { getTeamUser, getUser, getWebsite } from 'queries'; import { Auth } from './types'; const log = debug('umami:auth'); const cloudMode = process.env.CLOUD_MODE; +export async function checkAuth(request: Request) { + const token = request.headers.get('authorization')?.split(' ')?.[1]; + const payload = parseSecureToken(token, secret()); + const shareToken = await parseShareToken(request as any); + + let user = null; + const { userId, authKey, grant } = payload || {}; + + if (userId) { + user = await getUser(userId); + } else if (redisEnabled && authKey) { + const redis = getClient(); + + const key = await redis.get(authKey); + + if (key?.userId) { + user = await getUser(key.userId); + } + } + + if (process.env.NODE_ENV === 'development') { + log('checkAuth:', { token, shareToken, payload, user, grant }); + } + + if (!user?.id && !shareToken) { + log('checkAuth: User not authorized'); + return null; + } + + if (user) { + user.isAdmin = user.role === ROLES.admin; + } + + return { + user, + grant, + token, + shareToken, + authKey, + }; +} + export async function saveAuth(data: any, expire = 0) { const authKey = `auth:${getRandomChars(32)}`; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a9e13c14..cbc954e3 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -324,6 +324,20 @@ export const BROWSERS = { yandexbrowser: 'Yandex', }; +export const IP_ADDRESS_HEADERS = [ + 'cf-connecting-ip', + 'x-client-ip', + 'x-forwarded-for', + 'do-connecting-ip', + 'fastly-client-ip', + 'true-client-ip', + 'x-real-ip', + 'x-cluster-client-ip', + 'x-forwarded', + 'forwarded', + 'x-appengine-user-ip', +]; + export const MAP_FILE = '/datamaps.world.json'; export const ISO_COUNTRIES = { diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 0bea4403..9f1e04b7 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -1,34 +1,45 @@ import path from 'path'; -import { getClientIp } from 'request-ip'; import { browserName, detectOS } from 'detect-browser'; import isLocalhost from 'is-localhost-ip'; import ipaddr from 'ipaddr.js'; import maxmind from 'maxmind'; -import { safeDecodeURIComponent } from 'next-basics'; import { DESKTOP_OS, MOBILE_OS, DESKTOP_SCREEN_WIDTH, LAPTOP_SCREEN_WIDTH, MOBILE_SCREEN_WIDTH, + IP_ADDRESS_HEADERS, } from './constants'; -import { NextApiRequestCollect } from 'pages/api/send'; let lookup; -export function getIpAddress(req: NextApiRequestCollect) { - const customHeader = String(process.env.CLIENT_IP_HEADER).toLowerCase(); +export function getIpAddress(headers: Headers) { + const customHeader = process.env.CLIENT_IP_HEADER; - // Custom header - if (customHeader !== 'undefined' && req.headers[customHeader]) { - return req.headers[customHeader]; - } - // Cloudflare - else if (req.headers['cf-connecting-ip']) { - return req.headers['cf-connecting-ip']; + if (customHeader && headers.get(customHeader)) { + return headers.get(customHeader); } - return getClientIp(req); + const header = IP_ADDRESS_HEADERS.find(name => { + return headers.get(name); + }); + + const ip = headers.get(header); + + if (header === 'x-forwarded-for') { + return ip?.split(',')?.[0]?.trim(); + } + + if (header === 'forwarded') { + const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/); + + if (match) { + return match[1]; + } + } + + return ip; } export function getDevice(screen: string, os: string) { @@ -67,7 +78,7 @@ function getRegionCode(country: string, region: string) { return region.includes('-') ? region : `${country}-${region}`; } -function safeDecodeCfHeader(s: string | undefined | null): string | undefined | null { +function decodeHeader(s: string | undefined | null): string | undefined | null { if (s === undefined || s === null) { return s; } @@ -75,36 +86,38 @@ function safeDecodeCfHeader(s: string | undefined | null): string | undefined | return Buffer.from(s, 'latin1').toString('utf-8'); } -export async function getLocation(ip: string, req: NextApiRequestCollect) { +export async function getLocation(ip: string = '', headers: Headers) { // Ignore local ips if (await isLocalhost(ip)) { return; } - // Cloudflare headers - if (req.headers['cf-ipcountry']) { - const country = safeDecodeCfHeader(req.headers['cf-ipcountry']); - const subdivision1 = safeDecodeCfHeader(req.headers['cf-region-code']); - const city = safeDecodeCfHeader(req.headers['cf-ipcity']); + if (!process.env.SKIP_LOCATION_HEADERS) { + // Cloudflare headers + if (headers.get('cf-ipcountry')) { + const country = decodeHeader(headers.get('cf-ipcountry')); + const subdivision1 = decodeHeader(headers.get('cf-region-code')); + const city = decodeHeader(headers.get('cf-ipcity')); - return { - country, - subdivision1: getRegionCode(country, subdivision1), - city, - }; - } + return { + country, + subdivision1: getRegionCode(country, subdivision1), + city, + }; + } - // Vercel headers - if (req.headers['x-vercel-ip-country']) { - const country = safeDecodeURIComponent(req.headers['x-vercel-ip-country']); - const subdivision1 = safeDecodeURIComponent(req.headers['x-vercel-ip-country-region']); - const city = safeDecodeURIComponent(req.headers['x-vercel-ip-city']); + // Vercel headers + if (headers.get('x-vercel-ip-country')) { + const country = decodeHeader(headers.get('x-vercel-ip-country')); + const subdivision1 = decodeHeader(headers.get('x-vercel-ip-country-region')); + const city = decodeHeader(headers.get('x-vercel-ip-city')); - return { - country, - subdivision1: getRegionCode(country, subdivision1), - city, - }; + return { + country, + subdivision1: getRegionCode(country, subdivision1), + city, + }; + } } // Database lookup @@ -131,22 +144,22 @@ export async function getLocation(ip: string, req: NextApiRequestCollect) { } } -export async function getClientInfo(req: NextApiRequestCollect) { - const userAgent = req.body?.payload?.userAgent || req.headers['user-agent']; - const ip = req.body?.payload?.ip || getIpAddress(req); - const location = await getLocation(ip, req); - const country = location?.country; +export async function getClientInfo(request: Request, payload: Record) { + const userAgent = payload?.userAgent || request.headers.get('user-agent'); + const ip = payload?.ip || getIpAddress(request.headers); + const location = await getLocation(ip, request.headers); + const country = payload?.userAgent || location?.country; const subdivision1 = location?.subdivision1; const subdivision2 = location?.subdivision2; const city = location?.city; const browser = browserName(userAgent); const os = detectOS(userAgent) as string; - const device = getDevice(req.body?.payload?.screen, os); + const device = getDevice(payload?.screen, os); return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device }; } -export function hasBlockedIp(req: NextApiRequestCollect) { +export function hasBlockedIp(clientIp: string) { const ignoreIps = process.env.IGNORE_IP; if (ignoreIps) { @@ -156,17 +169,19 @@ export function hasBlockedIp(req: NextApiRequestCollect) { ips.push(...ignoreIps.split(',').map(n => n.trim())); } - const clientIp = getIpAddress(req); - return ips.find(ip => { - if (ip === clientIp) return true; + if (ip === clientIp) { + return true; + } // CIDR notation if (ip.indexOf('/') > 0) { const addr = ipaddr.parse(clientIp); const range = ipaddr.parseCIDR(ip); - if (addr.kind() === range[0].kind() && addr.match(range)) return true; + if (addr.kind() === range[0].kind() && addr.match(range)) { + return true; + } } }); } diff --git a/src/lib/load.ts b/src/lib/load.ts index 3650f233..0cb09da4 100644 --- a/src/lib/load.ts +++ b/src/lib/load.ts @@ -1,26 +1,20 @@ -import { serializeError } from 'serialize-error'; -import { getWebsiteSession, getWebsite } from 'queries'; import { Website, Session } from '@prisma/client'; import { getClient, redisEnabled } from '@umami/redis-client'; +import { getWebsiteSession, getWebsite } from 'queries'; export async function fetchWebsite(websiteId: string): Promise { let website = null; - try { - if (redisEnabled) { - const redis = getClient(); + if (redisEnabled) { + const redis = getClient(); - website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400); - } else { - website = await getWebsite(websiteId); - } + website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400); + } else { + website = await getWebsite(websiteId); + } - if (!website || website.deletedAt) { - return null; - } - } catch (e) { - // eslint-disable-next-line no-console - console.log('FETCH WEBSITE ERROR:', serializeError(e)); + if (!website || website.deletedAt) { + return null; } return website; @@ -29,21 +23,16 @@ export async function fetchWebsite(websiteId: string): Promise { export async function fetchSession(websiteId: string, sessionId: string): Promise { let session = null; - try { - if (redisEnabled) { - const redis = getClient(); + if (redisEnabled) { + const redis = getClient(); - session = await redis.fetch( - `session:${sessionId}`, - () => getWebsiteSession(websiteId, sessionId), - 86400, - ); - } else { - session = await getWebsiteSession(websiteId, sessionId); - } - } catch (e) { - // eslint-disable-next-line no-console - console.log('FETCH SESSION ERROR:', serializeError(e)); + session = await redis.fetch( + `session:${sessionId}`, + () => getWebsiteSession(websiteId, sessionId), + 86400, + ); + } else { + session = await getWebsiteSession(websiteId, sessionId); } if (!session) { diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts deleted file mode 100644 index 3f7b9504..00000000 --- a/src/lib/middleware.ts +++ /dev/null @@ -1,105 +0,0 @@ -import cors from 'cors'; -import debug from 'debug'; -import { getClient, redisEnabled } from '@umami/redis-client'; -import { getAuthToken, parseShareToken } from 'lib/auth'; -import { ROLES } from 'lib/constants'; -import { secret } from 'lib/crypto'; -import { getSession } from 'lib/session'; -import { - badRequest, - createMiddleware, - notFound, - parseSecureToken, - unauthorized, -} from 'next-basics'; -import { NextApiRequestCollect } from 'pages/api/send'; -import { getUser } from '../queries'; - -const log = debug('umami:middleware'); - -export const useCors = createMiddleware( - cors({ - // Cache CORS preflight request 24 hours by default - maxAge: Number(process.env.CORS_MAX_AGE) || 86400, - }), -); - -export const useSession = createMiddleware(async (req, res, next) => { - try { - const session = await getSession(req as NextApiRequestCollect); - - if (!session) { - log('useSession: Session not found'); - return badRequest(res, 'Session not found.'); - } - - (req as any).session = session; - } catch (e: any) { - if (e.message.startsWith('Website not found')) { - return notFound(res, e.message); - } - return badRequest(res, e.message); - } - - next(); -}); - -export const useAuth = createMiddleware(async (req, res, next) => { - const token = getAuthToken(req); - const payload = parseSecureToken(token, secret()); - const shareToken = await parseShareToken(req as any); - - let user = null; - const { userId, authKey, grant } = payload || {}; - - if (userId) { - user = await getUser(userId); - } else if (redisEnabled && authKey) { - const redis = getClient(); - - const key = await redis.get(authKey); - - if (key?.userId) { - user = await getUser(key.userId); - } - } - - if (process.env.NODE_ENV === 'development') { - log('useAuth:', { token, shareToken, payload, user, grant }); - } - - if (!user?.id && !shareToken) { - log('useAuth: User not authorized'); - return unauthorized(res); - } - - if (user) { - user.isAdmin = user.role === ROLES.admin; - } - - (req as any).auth = { - user, - grant, - token, - shareToken, - authKey, - }; - - next(); -}); - -export const useValidate = async (schema, req, res) => { - return createMiddleware(async (req: any, res, next) => { - try { - const rules = schema[req.method]; - - if (rules) { - rules.validateSync({ ...req.query, ...req.body }); - } - } catch (e: any) { - return badRequest(res, e.message); - } - - next(); - })(req, res); -}; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index cc1b8734..deee67f7 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -243,7 +243,7 @@ async function pagedQuery(model: string, criteria: T, pageParams: PageParams) const data = await prisma.client[model].findMany({ ...criteria, ...{ - ...(size > 0 && { take: +size, skip: +size * (page - 1) }), + ...(size > 0 && { take: +size, skip: +size * (+page - 1) }), ...(orderBy && { orderBy: [ { @@ -266,7 +266,7 @@ async function pagedRawQuery( ) { const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams; const size = +pageSize || DEFAULT_PAGE_SIZE; - const offset = +size * (page - 1); + const offset = +size * (+page - 1); const direction = sortDescending ? 'desc' : 'asc'; const statements = [ diff --git a/src/lib/request.ts b/src/lib/request.ts index 5e2be2fe..c71684b9 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,10 +1,55 @@ -import { NextApiRequest } from 'next'; +import { ZodObject } from 'zod'; +import { FILTER_COLUMNS } from 'lib/constants'; +import { badRequest, unauthorized } from 'lib/response'; import { getAllowedUnits, getMinimumUnit } from './date'; import { getWebsiteDateRange } from '../queries'; -import { FILTER_COLUMNS } from 'lib/constants'; +import { checkAuth } from 'lib/auth'; -export async function getRequestDateRange(req: NextApiRequest) { - const { websiteId, startAt, endAt, unit } = req.query; +export async function getJsonBody(request: Request) { + try { + return await request.clone().json(); + } catch { + return undefined; + } +} + +export async function parseRequest( + request: Request, + schema?: ZodObject, + options?: { skipAuth: boolean }, +): Promise { + const url = new URL(request.url); + let query = Object.fromEntries(url.searchParams); + let body = await getJsonBody(request); + let error: () => void | undefined; + let auth = null; + + if (schema) { + const isGet = request.method === 'GET'; + const result = schema.safeParse(isGet ? query : body); + + if (!result.success) { + error = () => badRequest(result.error); + } else if (isGet) { + query = result.data; + } else { + body = result.data; + } + } + + if (!options?.skipAuth && !error) { + auth = await checkAuth(request); + + if (!auth) { + error = () => unauthorized(); + } + } + + return { url, query, body, auth, error }; +} + +export async function getRequestDateRange(query: Record) { + const { websiteId, startAt, endAt, unit } = query; // All-time if (+startAt === 0 && +endAt === 1) { @@ -31,9 +76,9 @@ export async function getRequestDateRange(req: NextApiRequest) { }; } -export function getRequestFilters(req: NextApiRequest) { +export function getRequestFilters(query: Record) { return Object.keys(FILTER_COLUMNS).reduce((obj, key) => { - const value = req.query[key]; + const value = query[key]; if (value !== undefined) { obj[key] = value; diff --git a/src/lib/response.ts b/src/lib/response.ts new file mode 100644 index 00000000..7c99690f --- /dev/null +++ b/src/lib/response.ts @@ -0,0 +1,29 @@ +import { serializeError } from 'serialize-error'; + +export function ok() { + return Response.json({ ok: true }); +} + +export function json(data: any) { + return Response.json(data); +} + +export function badRequest(message?: any) { + return Response.json({ error: 'Bad request', message }, { status: 400 }); +} + +export function unauthorized(message?: any) { + return Response.json({ error: 'Unauthorized', message }, { status: 401 }); +} + +export function forbidden(message?: any) { + return Response.json({ error: 'Forbidden', message }, { status: 403 }); +} + +export function notFound(message?: any) { + return Response.json({ error: 'Not found', message }, { status: 404 }); +} + +export function serverError(error?: any) { + return Response.json({ error: 'Server error', message: serializeError(error) }, { status: 500 }); +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 5218af10..21da4f42 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -1,13 +1,59 @@ -import * as yup from 'yup'; +import { z } from 'zod'; +import { isValidTimezone } from 'lib/date'; +import { UNIT_TYPES } from './constants'; -export const dateRange = { - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), +export const filterParams = { + url: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + query: z.string().optional(), + os: z.string().optional(), + browser: z.string().optional(), + device: z.string().optional(), + country: z.string().optional(), + region: z.string().optional(), + city: z.string().optional(), + tag: z.string().optional(), + host: z.string().optional(), + language: z.string().optional(), + event: z.string().optional(), }; -export const pageInfo = { - query: yup.string(), - page: yup.number().integer().positive(), - pageSize: yup.number().integer().positive().min(1).max(200), - orderBy: yup.string(), +export const pagingParams = { + page: z.coerce.number().int().positive().optional(), + pageSize: z.coerce.number().int().positive().optional(), + orderBy: z.string().optional(), + query: z.string().optional(), +}; + +export const timezoneParam = z.string().refine(value => isValidTimezone(value), { + message: 'Invalid timezone', +}); + +export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), { + message: 'Invalid unit', +}); + +export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']); + +export const reportTypeParam = z.enum([ + 'funnel', + 'insights', + 'retention', + 'utm', + 'goals', + 'journey', + 'revenue', +]); + +export const reportParms = { + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + num: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + unit: z.string().optional(), + value: z.string().optional(), + }), }; diff --git a/src/lib/session.ts b/src/lib/session.ts deleted file mode 100644 index 0bfe0302..00000000 --- a/src/lib/session.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { secret, uuid, visitSalt } from 'lib/crypto'; -import { getClientInfo } from 'lib/detect'; -import { parseToken } from 'next-basics'; -import { NextApiRequestCollect } from 'pages/api/send'; -import { createSession } from 'queries'; -import clickhouse from './clickhouse'; -import { fetchSession, fetchWebsite } from './load'; -import { SessionData } from 'lib/types'; - -export async function getSession(req: NextApiRequestCollect): Promise { - const { payload } = req.body; - - if (!payload) { - throw new Error('Invalid payload.'); - } - - // Check if cache token is passed - const cacheToken = req.headers['x-umami-cache']; - - if (cacheToken) { - const result = await parseToken(cacheToken, secret()); - - // Token is valid - if (result) { - return result; - } - } - - // Verify payload - const { website: websiteId, hostname, screen, language } = payload; - - // Find website - const website = await fetchWebsite(websiteId); - - if (!website) { - throw new Error(`Website not found: ${websiteId}.`); - } - - const { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device } = - await getClientInfo(req); - - const sessionId = uuid(websiteId, hostname, ip, userAgent); - const visitId = uuid(sessionId, visitSalt()); - - // Clickhouse does not require session lookup - if (clickhouse.enabled) { - return { - id: sessionId, - websiteId, - visitId, - hostname, - browser, - os, - device, - screen, - language, - country, - subdivision1, - subdivision2, - city, - ip, - userAgent, - }; - } - - // Find session - let session = await fetchSession(websiteId, sessionId); - - // Create a session if not found - if (!session) { - try { - session = 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')) { - throw e; - } - } - } - - return { ...session, visitId }; -} diff --git a/src/lib/types.ts b/src/lib/types.ts index d7a12068..39e52642 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,3 @@ -import { NextApiRequest } from 'next'; import { COLLECTION_TYPE, DATA_TYPE, @@ -8,7 +7,6 @@ import { REPORT_TYPES, ROLES, } from './constants'; -import * as yup from 'yup'; import { TIME_UNIT } from './date'; import { Dispatch, SetStateAction } from 'react'; @@ -25,9 +23,9 @@ export type KafkaTopic = ObjectValues; export type ReportType = ObjectValues; export interface PageParams { - query?: string; - page?: number; - pageSize?: number; + search?: string; + page?: string; + pageSize?: string; orderBy?: string; sortDescending?: boolean; } @@ -65,26 +63,6 @@ export interface Auth { }; } -export interface YupRequest { - GET?: yup.ObjectSchema; - POST?: yup.ObjectSchema; - PUT?: yup.ObjectSchema; - DELETE?: yup.ObjectSchema; -} - -export interface NextApiRequestQueryBody extends NextApiRequest { - auth?: Auth; - query: TQuery & { [key: string]: string | string[] }; - body: TBody; - headers: any; - yup: YupRequest; -} - -export interface NextApiRequestAuth extends NextApiRequest { - auth?: Auth; - headers: any; -} - export interface User { id: string; username: string; diff --git a/src/lib/yup.ts b/src/lib/yup.ts deleted file mode 100644 index d2652eda..00000000 --- a/src/lib/yup.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as yup from 'yup'; -import { isValidTimezone } from 'lib/date'; -import { UNIT_TYPES } from './constants'; - -export const TimezoneTest = yup - .string() - .default('UTC') - .test( - 'timezone', - () => `Invalid timezone`, - value => isValidTimezone(value), - ); - -export const UnitTypeTest = yup.string().test( - 'unit', - () => `Invalid unit`, - value => UNIT_TYPES.includes(value), -); diff --git a/src/pages/api/admin/users.ts b/src/pages/api/admin/users.ts deleted file mode 100644 index 4f03ec9f..00000000 --- a/src/pages/api/admin/users.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { canViewUsers } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, Role, PageParams, User } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUsers } from 'queries'; -import * as yup from 'yup'; - -export interface UsersRequestQuery extends PageParams {} -export interface UsersRequestBody { - userId: string; - username: string; - password: string; - role: Role; -} - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), - POST: yup.object().shape({ - userId: yup.string().uuid(), - username: yup.string().max(255).required(), - password: yup.string().required(), - role: yup - .string() - .matches(/admin|user|view-only/i) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - if (!(await canViewUsers(req.auth))) { - return unauthorized(res); - } - - const users = await getUsers( - { - include: { - _count: { - select: { - websiteUser: { - where: { deletedAt: null }, - }, - }, - }, - }, - }, - req.query, - ); - - return ok(res, users); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/admin/websites.ts b/src/pages/api/admin/websites.ts deleted file mode 100644 index d7dd6b74..00000000 --- a/src/pages/api/admin/websites.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { canViewAllWebsites } from 'lib/auth'; -import { ROLES } from 'lib/constants'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { pageInfo } from 'lib/schema'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getWebsites } from 'queries'; -import * as yup from 'yup'; - -export interface WebsitesRequestQuery extends PageParams { - userId?: string; - includeOwnedTeams?: boolean; - includeAllTeams?: boolean; -} - -export interface WebsitesRequestBody { - name: string; - domain: string; - shareId: string; -} - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), - POST: yup.object().shape({ - name: yup.string().max(100).required(), - domain: yup.string().max(500).required(), - shareId: yup.string().max(50).nullable(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - if (!(await canViewAllWebsites(req.auth))) { - return unauthorized(res); - } - - const { userId, includeOwnedTeams, includeAllTeams } = req.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, - }, - }, - }, - }, - }, - }, - req.query, - ); - - return ok(res, websites); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts deleted file mode 100644 index fc671785..00000000 --- a/src/pages/api/auth/login.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { redisEnabled } from '@umami/redis-client'; -import { saveAuth } from 'lib/auth'; -import { secret } from 'lib/crypto'; -import { useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, User } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { - checkPassword, - createSecureToken, - forbidden, - methodNotAllowed, - ok, - unauthorized, -} from 'next-basics'; -import { getUserByUsername } from 'queries'; -import * as yup from 'yup'; -import { ROLES } from 'lib/constants'; - -export interface LoginRequestBody { - username: string; - password: string; -} - -export interface LoginResponse { - token: string; - user: User; -} - -const schema = { - POST: yup.object().shape({ - username: yup.string().required(), - password: yup.string().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - if (process.env.disableLogin) { - return forbidden(res); - } - - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { username, password } = req.body; - - const user = await getUserByUsername(username, { includePassword: true }); - - if (user && checkPassword(password, user.password)) { - if (redisEnabled) { - const token = await saveAuth({ userId: user.id }); - - return ok(res, { token, user }); - } - - const token = createSecureToken({ userId: user.id }, secret()); - const { id, username, role, createdAt } = user; - - return ok(res, { - token, - user: { id, username, role, createdAt, isAdmin: role === ROLES.admin }, - }); - } - - return unauthorized(res, 'message.incorrect-username-password'); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts deleted file mode 100644 index f1604989..00000000 --- a/src/pages/api/auth/logout.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { methodNotAllowed, ok } from 'next-basics'; -import { getClient, redisEnabled } from '@umami/redis-client'; -import { useAuth } from 'lib/middleware'; -import { getAuthToken } from 'lib/auth'; -import { NextApiRequest, NextApiResponse } from 'next'; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - await useAuth(req, res); - - if (req.method === 'POST') { - if (redisEnabled) { - const redis = getClient(); - - await redis.del(getAuthToken(req)); - } - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/auth/sso.ts b/src/pages/api/auth/sso.ts deleted file mode 100644 index c5560cb1..00000000 --- a/src/pages/api/auth/sso.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextApiRequestAuth } from 'lib/types'; -import { useAuth } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { badRequest, ok } from 'next-basics'; -import { redisEnabled } from '@umami/redis-client'; -import { saveAuth } from 'lib/auth'; - -export default async (req: NextApiRequestAuth, res: NextApiResponse) => { - await useAuth(req, res); - - if (redisEnabled && req.auth.user) { - const token = await saveAuth({ userId: req.auth.user.id }, 86400); - - return ok(res, { user: req.auth.user, token }); - } - - return badRequest(res); -}; diff --git a/src/pages/api/auth/verify.ts b/src/pages/api/auth/verify.ts deleted file mode 100644 index 3cc78ed3..00000000 --- a/src/pages/api/auth/verify.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextApiRequestAuth } from 'lib/types'; -import { useAuth } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { ok } from 'next-basics'; - -export default async (req: NextApiRequestAuth, res: NextApiResponse) => { - await useAuth(req, res); - - const { user } = req.auth; - - return ok(res, user); -}; diff --git a/src/pages/api/config.ts b/src/pages/api/config.ts deleted file mode 100644 index adba894a..00000000 --- a/src/pages/api/config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { ok, methodNotAllowed } from 'next-basics'; - -export interface ConfigResponse { - telemetryDisabled: boolean; - trackerScriptName: string; - uiDisabled: boolean; - updatesDisabled: boolean; -} - -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === 'GET') { - return ok(res, { - telemetryDisabled: !!process.env.DISABLE_TELEMETRY, - trackerScriptName: process.env.TRACKER_SCRIPT_NAME, - uiDisabled: !!process.env.DISABLE_UI, - updatesDisabled: !!process.env.DISABLE_UPDATES, - }); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/heartbeat.ts b/src/pages/api/heartbeat.ts deleted file mode 100644 index 1b515d39..00000000 --- a/src/pages/api/heartbeat.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { ok } from 'next-basics'; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - return ok(res); -}; diff --git a/src/pages/api/me/index.ts b/src/pages/api/me/index.ts deleted file mode 100644 index 93e97067..00000000 --- a/src/pages/api/me/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextApiResponse } from 'next'; -import { useAuth } from 'lib/middleware'; -import { NextApiRequestQueryBody, User } from 'lib/types'; -import { ok } from 'next-basics'; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - - return ok(res, req.auth.user); -}; diff --git a/src/pages/api/me/password.ts b/src/pages/api/me/password.ts deleted file mode 100644 index 2ba91d86..00000000 --- a/src/pages/api/me/password.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, User } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { - badRequest, - checkPassword, - forbidden, - hashPassword, - methodNotAllowed, - ok, -} from 'next-basics'; -import { getUser, updateUser } from 'queries'; -import * as yup from 'yup'; - -export interface UserPasswordRequestBody { - currentPassword: string; - newPassword: string; -} - -const schema = { - POST: yup.object().shape({ - currentPassword: yup.string().required(), - newPassword: yup.string().min(8).required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - if (process.env.CLOUD_MODE) { - return forbidden(res); - } - - await useAuth(req, res); - await useValidate(schema, req, res); - - const { currentPassword, newPassword } = req.body; - const { id: userId } = req.auth.user; - - if (req.method === 'POST') { - const user = await getUser(userId, { includePassword: true }); - - if (!checkPassword(currentPassword, user.password)) { - return badRequest(res, 'Current password is incorrect'); - } - - const password = hashPassword(newPassword); - - const updated = await updateUser(userId, { password }); - - return ok(res, updated); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/me/teams.ts b/src/pages/api/me/teams.ts deleted file mode 100644 index 3b88689d..00000000 --- a/src/pages/api/me/teams.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed } from 'next-basics'; -import userTeamsRoute from 'pages/api/users/[userId]/teams'; -import * as yup from 'yup'; - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - req.query.userId = req.auth.user.id; - - return userTeamsRoute(req, res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/me/websites.ts b/src/pages/api/me/websites.ts deleted file mode 100644 index 48800f90..00000000 --- a/src/pages/api/me/websites.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed } from 'next-basics'; -import userWebsitesRoute from 'pages/api/users/[userId]/websites'; -import * as yup from 'yup'; - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - req.query.userId = req.auth.user.id; - - return userWebsitesRoute(req, res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/realtime/[websiteId].ts b/src/pages/api/realtime/[websiteId].ts deleted file mode 100644 index 08e9bc47..00000000 --- a/src/pages/api/realtime/[websiteId].ts +++ /dev/null @@ -1,43 +0,0 @@ -import { startOfMinute, subMinutes } from 'date-fns'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getRealtimeData } from 'queries'; -import * as yup from 'yup'; -import { REALTIME_RANGE } from 'lib/constants'; -import { TimezoneTest } from 'lib/yup'; - -export interface RealtimeRequestQuery { - websiteId: string; - timezone?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - timezone: TimezoneTest, - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, timezone } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = subMinutes(startOfMinute(new Date()), REALTIME_RANGE); - - const data = await getRealtimeData(websiteId, { startDate, timezone }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts deleted file mode 100644 index 91b5fb51..00000000 --- a/src/pages/api/reports/[reportId].ts +++ /dev/null @@ -1,102 +0,0 @@ -import { canDeleteReport, canUpdateReport, canViewReport } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, ReportType, YupRequest } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { deleteReport, getReport, updateReport } from 'queries'; -import * as yup from 'yup'; - -export interface ReportRequestQuery { - reportId: string; -} - -export interface ReportRequestBody { - websiteId: string; - type: ReportType; - name: string; - description: string; - parameters: string; -} - -const schema: YupRequest = { - GET: yup.object().shape({ - reportId: yup.string().uuid().required(), - }), - POST: yup.object().shape({ - reportId: yup.string().uuid().required(), - websiteId: yup.string().uuid().required(), - type: yup - .string() - .matches(/funnel|insights|retention|utm|goals|journey|revenue/i) - .required(), - name: yup.string().max(200).required(), - description: yup.string().max(500), - parameters: yup - .object() - .test('len', 'Must not exceed 6000 characters.', val => JSON.stringify(val).length < 6000), - }), - DELETE: yup.object().shape({ - reportId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { reportId } = req.query; - const { - user: { id: userId }, - } = req.auth; - - if (req.method === 'GET') { - const report = await getReport(reportId); - - if (!(await canViewReport(req.auth, report))) { - return unauthorized(res); - } - - report.parameters = JSON.parse(report.parameters); - - return ok(res, report); - } - - if (req.method === 'POST') { - const { websiteId, type, name, description, parameters } = req.body; - - const report = await getReport(reportId); - - if (!(await canUpdateReport(req.auth, report))) { - return unauthorized(res); - } - - const result = await updateReport(reportId, { - websiteId, - userId, - type, - name, - description, - parameters: JSON.stringify(parameters), - } as any); - - return ok(res, result); - } - - if (req.method === 'DELETE') { - const report = await getReport(reportId); - - if (!(await canDeleteReport(req.auth, report))) { - return unauthorized(res); - } - - await deleteReport(reportId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/funnel.ts b/src/pages/api/reports/funnel.ts deleted file mode 100644 index 35759a30..00000000 --- a/src/pages/api/reports/funnel.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getFunnel } from 'queries'; -import * as yup from 'yup'; - -export interface FunnelRequestBody { - websiteId: string; - steps: { type: string; value: string }[]; - window: number; - dateRange: { - startDate: string; - endDate: string; - }; -} - -export interface FunnelResponse { - steps: { type: string; value: string }[]; - window: number; - startAt: number; - endAt: number; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - steps: yup - .array() - .of( - yup.object().shape({ - type: yup.string().required(), - value: yup.string().required(), - }), - ) - .min(2) - .required(), - window: yup.number().positive().required(), - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - }) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { - websiteId, - steps, - window, - dateRange: { startDate, endDate }, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getFunnel(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - steps, - windowMinutes: +window, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/goals.ts b/src/pages/api/reports/goals.ts deleted file mode 100644 index f775dc3c..00000000 --- a/src/pages/api/reports/goals.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { TimezoneTest } from 'lib/yup'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getGoals } from 'queries/analytics/reports/getGoals'; -import * as yup from 'yup'; - -export interface RetentionRequestBody { - websiteId: string; - dateRange: { startDate: string; endDate: string; timezone: string }; - goals: { type: string; value: string; goal: number }[]; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - timezone: TimezoneTest, - }) - .required(), - goals: yup - .array() - .of( - yup.object().shape({ - type: yup - .string() - .matches(/url|event|event-data/i) - .required(), - value: yup.string().required(), - goal: yup.number().required(), - operator: yup - .string() - .matches(/count|sum|average/i) - .when('type', { - is: 'eventData', - then: yup.string().required(), - }), - property: yup.string().when('type', { - is: 'eventData', - then: yup.string().required(), - }), - }), - ) - .min(1) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { - websiteId, - dateRange: { startDate, endDate }, - goals, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getGoals(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - goals, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/index.ts b/src/pages/api/reports/index.ts deleted file mode 100644 index 38996b7a..00000000 --- a/src/pages/api/reports/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { uuid } from 'lib/crypto'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createReport, getReports } from 'queries'; -import * as yup from 'yup'; -import { canUpdateWebsite, canViewTeam, canViewWebsite } from 'lib/auth'; - -export interface ReportRequestBody { - websiteId: string; - name: string; - type: string; - description: string; - parameters: { - [key: string]: any; - }; -} - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - name: yup.string().max(200).required(), - type: yup - .string() - .matches(/funnel|insights|retention|utm|goals|journey|revenue/i) - .required(), - description: yup.string().max(500), - parameters: yup - .object() - .test('len', 'Must not exceed 6000 characters.', val => JSON.stringify(val).length < 6000), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { - user: { id: userId }, - } = req.auth; - - if (req.method === 'GET') { - const { page, query, pageSize, websiteId, teamId } = req.query; - const filters = { - page, - pageSize, - query, - }; - - if ( - (websiteId && !(await canViewWebsite(req.auth, websiteId))) || - (teamId && !(await canViewTeam(req.auth, teamId))) - ) { - return unauthorized(res); - } - - 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 ok(res, data); - } - - if (req.method === 'POST') { - const { websiteId, type, name, description, parameters } = req.body; - - if (!(await canUpdateWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const result = await createReport({ - id: uuid(), - userId, - websiteId, - type, - name, - description, - parameters: JSON.stringify(parameters), - } as any); - - return ok(res, result); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/insights.ts b/src/pages/api/reports/insights.ts deleted file mode 100644 index ba4f643e..00000000 --- a/src/pages/api/reports/insights.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getInsights } from 'queries'; -import * as yup from 'yup'; - -export interface InsightsRequestBody { - websiteId: string; - dateRange: { - startDate: string; - endDate: string; - }; - fields: { name: string; type: string; label: string }[]; - filters: { name: string; type: string; operator: string; value: string }[]; - groups: { name: string; type: string }[]; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - }) - .required(), - fields: yup - .array() - .of( - yup.object().shape({ - name: yup.string().required(), - type: yup.string().required(), - label: yup.string().required(), - }), - ) - .min(1) - .required(), - filters: yup.array().of( - yup.object().shape({ - name: yup.string().required(), - type: yup.string().required(), - operator: yup.string().required(), - value: yup.string().required(), - }), - ), - groups: yup.array().of( - yup.object().shape({ - name: yup.string().required(), - type: yup.string().required(), - }), - ), - }), -}; - -function convertFilters(filters: any[]) { - return filters.reduce((obj, filter) => { - obj[filter.name] = filter; - - return obj; - }, {}); -} - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { - websiteId, - dateRange: { startDate, endDate }, - fields, - filters, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getInsights(websiteId, fields, { - ...convertFilters(filters), - startDate: new Date(startDate), - endDate: new Date(endDate), - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/journey.ts b/src/pages/api/reports/journey.ts deleted file mode 100644 index dd3bd57b..00000000 --- a/src/pages/api/reports/journey.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getJourney } from 'queries'; -import * as yup from 'yup'; - -export interface RetentionRequestBody { - websiteId: string; - dateRange: { startDate: string; endDate: string }; - steps: number; - startStep?: string; - endStep?: string; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - }) - .required(), - steps: yup.number().min(3).max(7).required(), - startStep: yup.string(), - endStep: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { - websiteId, - dateRange: { startDate, endDate }, - steps, - startStep, - endStep, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getJourney(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - steps, - startStep, - endStep, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/retention.ts b/src/pages/api/reports/retention.ts deleted file mode 100644 index f4d9b7df..00000000 --- a/src/pages/api/reports/retention.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { TimezoneTest } from 'lib/yup'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getRetention } from 'queries'; -import * as yup from 'yup'; - -export interface RetentionRequestBody { - websiteId: string; - dateRange: { startDate: string; endDate: string }; - timezone: string; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - }) - .required(), - timezone: TimezoneTest, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { - websiteId, - dateRange: { startDate, endDate }, - timezone, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getRetention(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - timezone, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/revenue.ts b/src/pages/api/reports/revenue.ts deleted file mode 100644 index d23ce55a..00000000 --- a/src/pages/api/reports/revenue.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { TimezoneTest, UnitTypeTest } from 'lib/yup'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getRevenue } from 'queries/analytics/reports/getRevenue'; -import { getRevenueValues } from 'queries/analytics/reports/getRevenueValues'; -import * as yup from 'yup'; - -export interface RevenueRequestBody { - websiteId: string; - currency?: string; - timezone?: string; - dateRange: { startDate: string; endDate: string; unit?: string }; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - timezone: TimezoneTest, - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - unit: UnitTypeTest, - }) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startDate, endDate } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getRevenueValues(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - }); - - return ok(res, data); - } - - if (req.method === 'POST') { - const { - websiteId, - currency, - timezone, - dateRange: { startDate, endDate, unit }, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getRevenue(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - unit, - timezone, - currency, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/reports/utm.ts b/src/pages/api/reports/utm.ts deleted file mode 100644 index 59399ee4..00000000 --- a/src/pages/api/reports/utm.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { TimezoneTest } from 'lib/yup'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUTM } from 'queries'; -import * as yup from 'yup'; - -export interface UTMRequestBody { - websiteId: string; - dateRange: { startDate: string; endDate: string; timezone: string }; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - dateRange: yup - .object() - .shape({ - startDate: yup.date().required(), - endDate: yup.date().required(), - timezone: TimezoneTest, - }) - .required(), - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { - websiteId, - dateRange: { startDate, endDate, timezone }, - } = req.body; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getUTM(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - timezone, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/send.ts b/src/pages/api/send.ts deleted file mode 100644 index a47b91d2..00000000 --- a/src/pages/api/send.ts +++ /dev/null @@ -1,172 +0,0 @@ -/* eslint-disable no-console */ -import { isbot } from 'isbot'; -import { NextApiRequest, NextApiResponse } from 'next'; -import { - badRequest, - createToken, - forbidden, - methodNotAllowed, - ok, - safeDecodeURI, -} from 'next-basics'; -import { COLLECTION_TYPE, HOSTNAME_REGEX, IP_REGEX } from 'lib/constants'; -import { secret, visitSalt, uuid } from 'lib/crypto'; -import { hasBlockedIp } from 'lib/detect'; -import { useCors, useSession, useValidate } from 'lib/middleware'; -import { CollectionType, YupRequest } from 'lib/types'; -import { saveEvent, saveSessionData } from 'queries'; -import * as yup from 'yup'; - -export interface CollectRequestBody { - payload: { - website: string; - data?: { [key: string]: any }; - hostname?: string; - language?: string; - name?: string; - referrer?: string; - screen?: string; - tag?: string; - title?: string; - url: string; - ip?: string; - userAgent?: string; - }; - type: CollectionType; -} - -export interface NextApiRequestCollect extends NextApiRequest { - body: CollectRequestBody; - session: { - id: string; - websiteId: string; - visitId: string; - hostname: string; - browser: string; - os: string; - device: string; - screen: string; - language: string; - country: string; - subdivision1: string; - subdivision2: string; - city: string; - iat: number; - }; - headers: { [key: string]: any }; - yup: YupRequest; -} - -const schema = { - POST: yup.object().shape({ - payload: yup - .object() - .shape({ - data: yup.object(), - hostname: yup.string().matches(HOSTNAME_REGEX).max(100), - language: yup.string().max(35), - referrer: yup.string(), - screen: yup.string().max(11), - title: yup.string(), - url: yup.string(), - website: yup.string().uuid().required(), - name: yup.string().max(50), - tag: yup.string().max(50).nullable(), - ip: yup.string().matches(IP_REGEX), - userAgent: yup.string(), - }) - .required(), - type: yup - .string() - .matches(/event|identify/i) - .required(), - }), -}; - -export default async (req: NextApiRequestCollect, res: NextApiResponse) => { - await useCors(req, res); - - if (req.method === 'POST') { - if (!process.env.DISABLE_BOT_CHECK && isbot(req.headers['user-agent'])) { - return ok(res, { beep: 'boop' }); - } - - await useValidate(schema, req, res); - - if (hasBlockedIp(req)) { - return forbidden(res); - } - - const { type, payload } = req.body; - const { url, referrer, name, data, title, tag } = payload; - - await useSession(req, res); - - const session = req.session; - - if (!session?.id || !session?.websiteId) { - return ok(res, {}); - } - - const iat = Math.floor(new Date().getTime() / 1000); - - // expire visitId after 30 minutes - if (session.iat && iat - session.iat > 1800) { - session.visitId = uuid(session.id, visitSalt()); - } - - session.iat = iat; - - if (type === COLLECTION_TYPE.event) { - // eslint-disable-next-line prefer-const - let [urlPath, urlQuery] = safeDecodeURI(url)?.split('?') || []; - let [referrerPath, referrerQuery] = safeDecodeURI(referrer)?.split('?') || []; - let referrerDomain = ''; - - if (!urlPath) { - urlPath = '/'; - } - - if (/^[\w-]+:\/\/\w+/.test(referrerPath)) { - const refUrl = new URL(referrer); - referrerPath = refUrl.pathname; - referrerQuery = refUrl.search.substring(1); - referrerDomain = refUrl.hostname.replace(/www\./, ''); - } - - if (process.env.REMOVE_TRAILING_SLASH) { - urlPath = urlPath.replace(/(.+)\/$/, '$1'); - } - - await saveEvent({ - urlPath, - urlQuery, - referrerPath, - referrerQuery, - referrerDomain, - pageTitle: title, - eventName: name, - eventData: data, - ...session, - sessionId: session.id, - tag, - }); - } else if (type === COLLECTION_TYPE.identify) { - if (!data) { - return badRequest(res, 'Data required.'); - } - - await saveSessionData({ - websiteId: session.websiteId, - sessionId: session.id, - sessionData: data, - }); - } - - const cache = createToken(session, secret()); - - return ok(res, { cache }); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/share/[shareId].ts b/src/pages/api/share/[shareId].ts deleted file mode 100644 index 26ac4cdc..00000000 --- a/src/pages/api/share/[shareId].ts +++ /dev/null @@ -1,46 +0,0 @@ -import { secret } from 'lib/crypto'; -import { useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { createToken, methodNotAllowed, notFound, ok } from 'next-basics'; -import { getSharedWebsite } from 'queries'; -import * as yup from 'yup'; - -export interface ShareRequestQuery { - shareId: string; -} - -export interface ShareResponse { - shareId: string; - token: string; -} - -const schema = { - GET: yup.object().shape({ - shareId: yup.string().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useValidate(schema, req, res); - - const { shareId } = req.query; - - if (req.method === 'GET') { - const website = await getSharedWebsite(shareId); - - if (website) { - const data = { websiteId: website.id }; - const token = createToken(data, secret()); - - return ok(res, { ...data, token }); - } - - return notFound(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/[teamId]/index.ts b/src/pages/api/teams/[teamId]/index.ts deleted file mode 100644 index b731ee0c..00000000 --- a/src/pages/api/teams/[teamId]/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Team } from '@prisma/client'; -import { canDeleteTeam, canUpdateTeam, canViewTeam } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, notFound, ok, unauthorized } from 'next-basics'; -import { deleteTeam, getTeam, updateTeam } from 'queries'; -import * as yup from 'yup'; - -export interface TeamRequestQuery { - teamId: string; -} - -export interface TeamRequestBody { - name: string; - accessCode: string; -} - -const schema = { - GET: yup.object().shape({ - teamId: yup.string().uuid().required(), - }), - POST: yup.object().shape({ - id: yup.string().uuid().required(), - name: yup.string().max(50), - accessCode: yup.string().max(50), - }), - DELETE: yup.object().shape({ - teamId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { teamId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewTeam(req.auth, teamId))) { - return unauthorized(res); - } - - const team = await getTeam(teamId, { includeMembers: true }); - - if (!team) { - return notFound(res); - } - - return ok(res, team); - } - - if (req.method === 'POST') { - if (!(await canUpdateTeam(req.auth, teamId))) { - return unauthorized(res, 'You must be the owner of this team.'); - } - - const { name, accessCode } = req.body; - const data = { name, accessCode }; - - const updated = await updateTeam(teamId, data); - - return ok(res, updated); - } - - if (req.method === 'DELETE') { - if (!(await canDeleteTeam(req.auth, teamId))) { - return unauthorized(res, 'You must be the owner of this team.'); - } - - await deleteTeam(teamId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/[teamId]/users/[userId].ts b/src/pages/api/teams/[teamId]/users/[userId].ts deleted file mode 100644 index c1e80b1a..00000000 --- a/src/pages/api/teams/[teamId]/users/[userId].ts +++ /dev/null @@ -1,85 +0,0 @@ -import { canDeleteTeamUser, canUpdateTeam } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { deleteTeamUser, getTeamUser, updateTeamUser } from 'queries'; -import * as yup from 'yup'; - -export interface TeamUserRequestQuery { - teamId: string; - userId: string; -} - -export interface TeamUserRequestBody { - role: string; -} - -const schema = { - DELETE: yup.object().shape({ - teamId: yup.string().uuid().required(), - userId: yup.string().uuid().required(), - }), - POST: yup.object().shape({ - role: yup - .string() - .matches(/team-member|team-view-only|team-manager/i) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { teamId, userId } = req.query; - - if (req.method === 'GET') { - if (!(await canUpdateTeam(req.auth, teamId))) { - return unauthorized(res, 'You must be the owner of this team.'); - } - - const teamUser = await getTeamUser(teamId, userId); - - return ok(res, teamUser); - } - - if (req.method === 'POST') { - if (!(await canUpdateTeam(req.auth, teamId))) { - return unauthorized(res, 'You must be the owner of this team.'); - } - - const teamUser = await getTeamUser(teamId, userId); - - if (!teamUser) { - return badRequest(res, 'The User does not exists on this team.'); - } - - const { role } = req.body; - - await updateTeamUser(teamUser.id, { role }); - - return ok(res); - } - - if (req.method === 'DELETE') { - if (!(await canDeleteTeamUser(req.auth, teamId, userId))) { - return unauthorized(res, 'You must be the owner of this team.'); - } - - const teamUser = await getTeamUser(teamId, userId); - - if (!teamUser) { - return badRequest(res, 'The User does not exists on this team.'); - } - - await deleteTeamUser(teamId, userId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/[teamId]/users/index.ts b/src/pages/api/teams/[teamId]/users/index.ts deleted file mode 100644 index f25b99da..00000000 --- a/src/pages/api/teams/[teamId]/users/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { canAddUserToTeam, canViewTeam } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { pageInfo } from 'lib/schema'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createTeamUser, getTeamUser, getTeamUsers } from 'queries'; -import * as yup from 'yup'; - -export interface TeamUserRequestQuery extends PageParams { - teamId: string; -} - -export interface TeamUserRequestBody { - userId: string; - role: string; -} - -const schema = { - GET: yup.object().shape({ - teamId: yup.string().uuid().required(), - ...pageInfo, - }), - POST: yup.object().shape({ - userId: yup.string().uuid().required(), - role: yup - .string() - .matches(/team-member|team-view-only|team-manager/i) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { teamId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewTeam(req.auth, teamId))) { - return unauthorized(res); - } - - const users = await getTeamUsers( - { - where: { - teamId, - user: { - deletedAt: null, - }, - }, - include: { - user: { - select: { - id: true, - username: true, - }, - }, - }, - }, - req.query, - ); - - return ok(res, users); - } - - // admin function only - if (req.method === 'POST') { - if (!(await canAddUserToTeam(req.auth))) { - return unauthorized(res); - } - - const { userId, role } = req.body; - - const teamUser = await getTeamUser(teamId, userId); - - if (teamUser) { - return badRequest(res, 'User is already a member of the Team.'); - } - - const users = await createTeamUser(userId, teamId, role); - - return ok(res, users); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/[teamId]/websites/index.ts b/src/pages/api/teams/[teamId]/websites/index.ts deleted file mode 100644 index 75020fa4..00000000 --- a/src/pages/api/teams/[teamId]/websites/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as yup from 'yup'; -import { canViewTeam } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { ok, unauthorized } from 'next-basics'; -import { getTeamWebsites } from 'queries'; - -export interface TeamWebsiteRequestQuery extends PageParams { - teamId: string; -} - -const schema = { - GET: yup.object().shape({ - teamId: yup.string().uuid().required(), - ...pageInfo, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { teamId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewTeam(req.auth, teamId))) { - return unauthorized(res); - } - - const websites = await getTeamWebsites(teamId, req.query); - - return ok(res, websites); - } -}; diff --git a/src/pages/api/teams/index.ts b/src/pages/api/teams/index.ts deleted file mode 100644 index 1e683469..00000000 --- a/src/pages/api/teams/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as yup from 'yup'; -import { Team } from '@prisma/client'; -import { canCreateTeam } from 'lib/auth'; -import { uuid } from 'lib/crypto'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { getRandomChars, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createTeam } from 'queries'; - -export interface TeamsRequestQuery extends PageParams {} -export interface TeamsRequestBody { - name: string; -} - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), - POST: yup.object().shape({ - name: yup.string().max(50).required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { - user: { id: userId }, - } = req.auth; - - if (req.method === 'POST') { - if (!(await canCreateTeam(req.auth))) { - return unauthorized(res); - } - - const { name } = req.body; - - const team = await createTeam( - { - id: uuid(), - name, - accessCode: getRandomChars(16), - }, - userId, - ); - - return ok(res, team); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/join.ts b/src/pages/api/teams/join.ts deleted file mode 100644 index a9943f64..00000000 --- a/src/pages/api/teams/join.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Team } from '@prisma/client'; -import { ROLES } from 'lib/constants'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, notFound, ok } from 'next-basics'; -import { createTeamUser, findTeam, getTeamUser } from 'queries'; -import * as yup from 'yup'; - -export interface TeamsJoinRequestBody { - accessCode: string; -} - -const schema = { - POST: yup.object().shape({ - accessCode: yup.string().max(50).required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - const { accessCode } = req.body; - - const team = await findTeam({ - where: { - accessCode, - }, - }); - - if (!team) { - return notFound(res, 'message.team-not-found'); - } - - const teamUser = await getTeamUser(team.id, req.auth.user.id); - - if (teamUser) { - return methodNotAllowed(res, 'message.team-already-member'); - } - - await createTeamUser(req.auth.user.id, team.id, ROLES.teamMember); - - return ok(res, team); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/users/[userId]/index.ts b/src/pages/api/users/[userId]/index.ts deleted file mode 100644 index d69cad3c..00000000 --- a/src/pages/api/users/[userId]/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as yup from 'yup'; -import { canDeleteUser, canUpdateUser, canViewUser } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, Role, User } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { deleteUser, getUser, getUserByUsername, updateUser } from 'queries'; - -export interface UserRequestQuery { - userId: string; -} - -export interface UserRequestBody { - userId: string; - username: string; - password: string; - role: Role; -} - -const schema = { - GET: yup.object().shape({ - userId: yup.string().uuid().required(), - }), - POST: yup.object().shape({ - userId: yup.string().uuid().required(), - username: yup.string().max(255), - password: yup.string(), - role: yup.string().matches(/admin|user|view-only/i), - }), - DELETE: yup.object().shape({ - userId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { - user: { isAdmin }, - } = req.auth; - const userId: string = req.query.userId; - - if (req.method === 'GET') { - if (!(await canViewUser(req.auth, userId))) { - return unauthorized(res); - } - - const user = await getUser(userId); - - return ok(res, user); - } - - if (req.method === 'POST') { - if (!(await canUpdateUser(req.auth, userId))) { - return unauthorized(res); - } - - const { username, password, role } = req.body; - - const user = await getUser(userId); - - const data: any = {}; - - if (password) { - data.password = hashPassword(password); - } - - // Only admin can change these fields - if (role && isAdmin) { - data.role = role; - } - - if (username && isAdmin) { - data.username = username; - } - - // Check when username changes - if (data.username && user.username !== data.username) { - const user = await getUserByUsername(username); - - if (user) { - return badRequest(res, 'User already exists'); - } - } - - const updated = await updateUser(userId, data); - - return ok(res, updated); - } - - if (req.method === 'DELETE') { - if (!(await canDeleteUser(req.auth))) { - return unauthorized(res); - } - - if (userId === req.auth.user.id) { - return badRequest(res, 'You cannot delete yourself.'); - } - - await deleteUser(userId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/users/[userId]/teams.ts b/src/pages/api/users/[userId]/teams.ts deleted file mode 100644 index 3f2af9e2..00000000 --- a/src/pages/api/users/[userId]/teams.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as yup from 'yup'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUserTeams } from 'queries'; - -export interface UserTeamsRequestQuery extends PageParams { - userId: string; -} - -const schema = { - GET: yup.object().shape({ - userId: yup.string().uuid().required(), - ...pageInfo, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { user } = req.auth; - const { userId } = req.query; - - if (req.method === 'GET') { - if (!user.isAdmin && (!userId || user.id !== userId)) { - return unauthorized(res); - } - - const teams = await getUserTeams(userId as string, req.query); - - return ok(res, teams); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/users/[userId]/usage.ts b/src/pages/api/users/[userId]/usage.ts deleted file mode 100644 index b5000395..00000000 --- a/src/pages/api/users/[userId]/usage.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getAllUserWebsitesIncludingTeamOwner, getEventDataUsage, getEventUsage } from 'queries'; -import * as yup from 'yup'; - -export interface UserUsageRequestQuery { - userId: string; - startAt: string; - endAt: string; -} - -export interface UserUsageRequestResponse { - websiteEventUsage: number; - eventDataUsage: number; - websites: { - websiteEventUsage: number; - eventDataUsage: number; - websiteId: string; - websiteName: string; - }[]; -} - -const schema = { - GET: yup.object().shape({ - userId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { user } = req.auth; - - if (req.method === 'GET') { - if (!user.isAdmin) { - return unauthorized(res); - } - - const { userId, startAt, endAt } = req.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 ok(res, { - ...usage, - websites: filteredWebsiteUsage, - }); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/users/[userId]/websites.ts b/src/pages/api/users/[userId]/websites.ts deleted file mode 100644 index 88a2bad1..00000000 --- a/src/pages/api/users/[userId]/websites.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUserWebsites } from 'queries'; -import * as yup from 'yup'; - -const schema = { - GET: yup.object().shape({ - userId: yup.string().uuid().required(), - ...pageInfo, - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { user } = req.auth; - const { userId, page = 1, pageSize, query = '', ...rest } = req.query; - - if (req.method === 'GET') { - if (!user.isAdmin && user.id !== userId) { - return unauthorized(res); - } - - const websites = await getUserWebsites(userId, { - page, - pageSize, - query, - ...rest, - }); - - return ok(res, websites); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/users/index.ts b/src/pages/api/users/index.ts deleted file mode 100644 index 333670a9..00000000 --- a/src/pages/api/users/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { canCreateUser } from 'lib/auth'; -import { ROLES } from 'lib/constants'; -import { uuid } from 'lib/crypto'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, Role, PageParams, User } from 'lib/types'; -import { pageInfo } from 'lib/schema'; -import { NextApiResponse } from 'next'; -import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createUser, getUserByUsername } from 'queries'; -import * as yup from 'yup'; - -export interface UsersRequestQuery extends PageParams {} -export interface UsersRequestBody { - username: string; - password: string; - id: string; - role: Role; -} - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), - POST: yup.object().shape({ - username: yup.string().max(255).required(), - password: yup.string().required(), - id: yup.string().uuid(), - role: yup - .string() - .matches(/admin|user|view-only/i) - .required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'POST') { - if (!(await canCreateUser(req.auth))) { - return unauthorized(res); - } - - const { username, password, role, id } = req.body; - - const existingUser = await getUserByUsername(username, { showDeleted: true }); - - if (existingUser) { - return badRequest(res, 'User already exists'); - } - - const created = await createUser({ - id: id || uuid(), - username, - password: hashPassword(password), - role: role ?? ROLES.user, - }); - - return ok(res, created); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/version.ts b/src/pages/api/version.ts deleted file mode 100644 index 4453b56f..00000000 --- a/src/pages/api/version.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { ok, methodNotAllowed } from 'next-basics'; -import { CURRENT_VERSION } from 'lib/constants'; - -export interface VersionResponse { - version: string; -} - -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === 'GET') { - return ok(res, { - version: CURRENT_VERSION, - }); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/active.ts b/src/pages/api/websites/[websiteId]/active.ts deleted file mode 100644 index d87a7818..00000000 --- a/src/pages/api/websites/[websiteId]/active.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { WebsiteActive, NextApiRequestQueryBody } from 'lib/types'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getActiveVisitors } from 'queries'; -import * as yup from 'yup'; - -export interface WebsiteActiveRequestQuery { - websiteId: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId as string))) { - return unauthorized(res); - } - - const result = await getActiveVisitors(websiteId as string); - - return ok(res, result); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/daterange.ts b/src/pages/api/websites/[websiteId]/daterange.ts deleted file mode 100644 index 1aeb76cb..00000000 --- a/src/pages/api/websites/[websiteId]/daterange.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { WebsiteActive, NextApiRequestQueryBody } from 'lib/types'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getWebsiteDateRange } from 'queries'; -import * as yup from 'yup'; - -export interface WebsiteDateRangeRequestQuery { - websiteId: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const result = await getWebsiteDateRange(websiteId); - - return ok(res, result); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/event-data/events.ts b/src/pages/api/websites/[websiteId]/event-data/events.ts deleted file mode 100644 index bf0f409a..00000000 --- a/src/pages/api/websites/[websiteId]/event-data/events.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getEventDataEvents } from 'queries'; -import * as yup from 'yup'; - -export interface EventDataFieldsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; - event?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - event: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt, event } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getEventDataEvents(websiteId, { - startDate, - endDate, - event, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/event-data/fields.ts b/src/pages/api/websites/[websiteId]/event-data/fields.ts deleted file mode 100644 index c5075c5e..00000000 --- a/src/pages/api/websites/[websiteId]/event-data/fields.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getEventDataFields } from 'queries'; - -import * as yup from 'yup'; - -export interface EventDataFieldsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getEventDataFields(websiteId, { - startDate, - endDate, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/event-data/properties.ts b/src/pages/api/websites/[websiteId]/event-data/properties.ts deleted file mode 100644 index 19e9bbb8..00000000 --- a/src/pages/api/websites/[websiteId]/event-data/properties.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getEventDataProperties } from 'queries'; -import * as yup from 'yup'; - -export interface EventDataFieldsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; - propertyName?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - propertyName: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt, propertyName } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getEventDataProperties(websiteId, { startDate, endDate, propertyName }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/event-data/stats.ts b/src/pages/api/websites/[websiteId]/event-data/stats.ts deleted file mode 100644 index 7e440b88..00000000 --- a/src/pages/api/websites/[websiteId]/event-data/stats.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getEventDataStats } from 'queries/index'; -import * as yup from 'yup'; - -export interface EventDataStatsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getEventDataStats(websiteId, { startDate, endDate }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/event-data/values.ts b/src/pages/api/websites/[websiteId]/event-data/values.ts deleted file mode 100644 index e5bb4ab8..00000000 --- a/src/pages/api/websites/[websiteId]/event-data/values.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getEventDataValues } from 'queries'; - -import * as yup from 'yup'; - -export interface EventDataFieldsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; - eventName?: string; - propertyName?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - eventName: yup.string(), - propertyName: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt, eventName, propertyName } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getEventDataValues(websiteId, { - startDate, - endDate, - eventName, - propertyName, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/events/index.ts b/src/pages/api/websites/[websiteId]/events/index.ts deleted file mode 100644 index 13b31fc0..00000000 --- a/src/pages/api/websites/[websiteId]/events/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { pageInfo } from 'lib/schema'; -import { getWebsiteEvents } from 'queries'; - -export interface ReportsRequestQuery extends PageParams { - websiteId: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - ...pageInfo, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, startAt, endAt } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getWebsiteEvents(websiteId, { startDate, endDate }, req.query); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/events/series.ts b/src/pages/api/websites/[websiteId]/events/series.ts deleted file mode 100644 index 6d67a264..00000000 --- a/src/pages/api/websites/[websiteId]/events/series.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { getRequestDateRange, getRequestFilters } from 'lib/request'; -import { NextApiRequestQueryBody, WebsiteMetric } from 'lib/types'; -import { TimezoneTest, UnitTypeTest } from 'lib/yup'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getEventMetrics } from 'queries'; -import * as yup from 'yup'; - -export interface WebsiteEventsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; - unit?: string; - timezone?: string; - url: string; - referrer?: string; - title?: string; - host?: string; - os?: string; - browser?: string; - device?: string; - country?: string; - region: string; - city?: string; - tag?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - unit: UnitTypeTest, - timezone: TimezoneTest, - url: yup.string(), - referrer: yup.string(), - title: yup.string(), - host: yup.string(), - os: yup.string(), - browser: yup.string(), - device: yup.string(), - country: yup.string(), - region: yup.string(), - city: yup.string(), - tag: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, timezone } = req.query; - const { startDate, endDate, unit } = await getRequestDateRange(req); - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const filters = { - ...getRequestFilters(req), - startDate, - endDate, - timezone, - unit, - }; - - const events = await getEventMetrics(websiteId, filters); - - return ok(res, events); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/index.ts b/src/pages/api/websites/[websiteId]/index.ts deleted file mode 100644 index c60a8399..00000000 --- a/src/pages/api/websites/[websiteId]/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, serverError, unauthorized } from 'next-basics'; -import { Website, NextApiRequestQueryBody } from 'lib/types'; -import { canViewWebsite, canUpdateWebsite, canDeleteWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { deleteWebsite, getWebsite, updateWebsite } from 'queries'; -import { SHARE_ID_REGEX } from 'lib/constants'; - -export interface WebsiteRequestQuery { - websiteId: string; -} - -export interface WebsiteRequestBody { - name: string; - domain: string; - shareId: string; -} - -import * as yup from 'yup'; - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - }), - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - name: yup.string(), - domain: yup.string(), - shareId: yup.string().matches(SHARE_ID_REGEX, { excludeEmptyString: true }).nullable(), - }), - DELETE: yup.object().shape({ - websiteId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const website = await getWebsite(websiteId); - - return ok(res, website); - } - - if (req.method === 'POST') { - if (!(await canUpdateWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const { name, domain, shareId } = req.body; - - try { - const website = await updateWebsite(websiteId, { name, domain, shareId }); - - return ok(res, website); - } catch (e: any) { - if (e.message.includes('Unique constraint') && e.message.includes('share_id')) { - return serverError(res, 'That share ID is already taken.'); - } - - return serverError(res, e); - } - } - - if (req.method === 'DELETE') { - if (!(await canDeleteWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - await deleteWebsite(websiteId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/metrics.ts b/src/pages/api/websites/[websiteId]/metrics.ts deleted file mode 100644 index 1996a61a..00000000 --- a/src/pages/api/websites/[websiteId]/metrics.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { NextApiResponse } from 'next'; -import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { WebsiteMetric, NextApiRequestQueryBody } from 'lib/types'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants'; -import { getPageviewMetrics, getSessionMetrics } from 'queries'; -import { getRequestFilters, getRequestDateRange } from 'lib/request'; -import * as yup from 'yup'; - -export interface WebsiteMetricsRequestQuery { - websiteId: string; - type: string; - startAt: number; - endAt: number; - url?: string; - referrer?: string; - title?: string; - query?: string; - host?: string; - os?: string; - browser?: string; - device?: string; - country?: string; - region?: string; - city?: string; - language?: string; - event?: string; - limit?: number; - offset?: number; - search?: string; - tag?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - type: yup.string().required(), - startAt: yup.number().required(), - endAt: yup.number().required(), - url: yup.string(), - referrer: yup.string(), - title: yup.string(), - query: yup.string(), - host: yup.string(), - os: yup.string(), - browser: yup.string(), - device: yup.string(), - country: yup.string(), - region: yup.string(), - city: yup.string(), - language: yup.string(), - event: yup.string(), - limit: yup.number(), - offset: yup.number(), - search: yup.string(), - tag: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, type, limit, offset, search } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const { startDate, endDate } = await getRequestDateRange(req); - const column = FILTER_COLUMNS[type] || type; - const filters = { - ...getRequestFilters(req), - 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 ok(res, Object.values(combined)); - } - - return ok(res, data); - } - - if (EVENT_COLUMNS.includes(type)) { - const data = await getPageviewMetrics(websiteId, type, filters, limit, offset); - - return ok(res, data); - } - - return badRequest(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/pageviews.ts b/src/pages/api/websites/[websiteId]/pageviews.ts deleted file mode 100644 index c3b6b797..00000000 --- a/src/pages/api/websites/[websiteId]/pageviews.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { getRequestFilters, getRequestDateRange } from 'lib/request'; -import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getPageviewStats, getSessionStats } from 'queries'; -import { TimezoneTest, UnitTypeTest } from 'lib/yup'; -import { getCompareDate } from 'lib/date'; - -export interface WebsitePageviewRequestQuery { - websiteId: string; - startAt: number; - endAt: number; - unit?: string; - timezone?: string; - url?: string; - referrer?: string; - title?: string; - host?: string; - os?: string; - browser?: string; - device?: string; - country?: string; - region: string; - city?: string; - tag?: string; - compare?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().required(), - endAt: yup.number().required(), - unit: UnitTypeTest, - timezone: TimezoneTest, - url: yup.string(), - referrer: yup.string(), - title: yup.string(), - host: yup.string(), - os: yup.string(), - browser: yup.string(), - device: yup.string(), - country: yup.string(), - region: yup.string(), - city: yup.string(), - tag: yup.string(), - compare: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, timezone, compare } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const { startDate, endDate, unit } = await getRequestDateRange(req); - - const filters = { - ...getRequestFilters(req), - 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 ok(res, { - pageviews, - sessions, - startDate, - endDate, - compare: { - pageviews: comparePageviews, - sessions: compareSessions, - startDate: compareStartDate, - endDate: compareEndDate, - }, - }); - } - - return ok(res, { pageviews, sessions }); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/reports.ts b/src/pages/api/websites/[websiteId]/reports.ts deleted file mode 100644 index 72e5b0f2..00000000 --- a/src/pages/api/websites/[websiteId]/reports.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getWebsiteReports } from 'queries'; -import { pageInfo } from 'lib/schema'; - -export interface ReportsRequestQuery extends PageParams { - websiteId: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - ...pageInfo, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const { page, query, pageSize } = req.query; - - const data = await getWebsiteReports(websiteId, { - page, - pageSize, - query, - }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/reset.ts b/src/pages/api/websites/[websiteId]/reset.ts deleted file mode 100644 index 82e769dc..00000000 --- a/src/pages/api/websites/[websiteId]/reset.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextApiRequestQueryBody } from 'lib/types'; -import { canUpdateWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { resetWebsite } from 'queries'; -import * as yup from 'yup'; - -export interface WebsiteResetRequestQuery { - websiteId: string; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - - if (req.method === 'POST') { - if (!(await canUpdateWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - await resetWebsite(websiteId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/session-data/properties.ts b/src/pages/api/websites/[websiteId]/session-data/properties.ts deleted file mode 100644 index 92e182d2..00000000 --- a/src/pages/api/websites/[websiteId]/session-data/properties.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getSessionDataProperties } from 'queries'; -import * as yup from 'yup'; - -export interface SessionDataFieldsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; - propertyName?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - propertyName: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt, propertyName } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getSessionDataProperties(websiteId, { startDate, endDate, propertyName }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/session-data/values.ts b/src/pages/api/websites/[websiteId]/session-data/values.ts deleted file mode 100644 index 98463f15..00000000 --- a/src/pages/api/websites/[websiteId]/session-data/values.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getSessionDataValues } from 'queries'; - -import * as yup from 'yup'; - -export interface EventDataFieldsRequestQuery { - websiteId: string; - startAt: string; - endAt: string; - propertyName?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - propertyName: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, startAt, endAt, propertyName } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getSessionDataValues(websiteId, { startDate, endDate, propertyName }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts deleted file mode 100644 index 2b0fc084..00000000 --- a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getSessionActivity } from 'queries'; - -export interface SessionActivityRequestQuery extends PageParams { - websiteId: string; - sessionId: string; - startAt: number; - endAt: number; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - sessionId: yup.string().uuid().required(), - startAt: yup.number().integer(), - endAt: yup.number().integer(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, sessionId, startAt, endAt } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getSessionActivity(websiteId, sessionId, startDate, endDate); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/index.ts b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/index.ts deleted file mode 100644 index f627a208..00000000 --- a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getWebsiteSession } from 'queries'; - -export interface WesiteSessionRequestQuery extends PageParams { - websiteId: string; - sessionId: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - sessionId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, sessionId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getWebsiteSession(websiteId, sessionId); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/properties.ts b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/properties.ts deleted file mode 100644 index c0c20064..00000000 --- a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/properties.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getSessionData } from 'queries'; -import * as yup from 'yup'; - -export interface SessionDataRequestQuery { - sessionId: string; - websiteId: string; -} - -const schema = { - GET: yup.object().shape({ - sessionId: yup.string().uuid().required(), - websiteId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'GET') { - const { websiteId, sessionId } = req.query; - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const data = await getSessionData(websiteId, sessionId); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/sessions/index.ts b/src/pages/api/websites/[websiteId]/sessions/index.ts deleted file mode 100644 index 1809929c..00000000 --- a/src/pages/api/websites/[websiteId]/sessions/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { pageInfo } from 'lib/schema'; -import { getWebsiteSessions } from 'queries'; - -export interface ReportsRequestQuery extends PageParams { - websiteId: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - ...pageInfo, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, startAt, endAt } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getWebsiteSessions(websiteId, { startDate, endDate }, req.query); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/sessions/stats.ts b/src/pages/api/websites/[websiteId]/sessions/stats.ts deleted file mode 100644 index fe92ce6f..00000000 --- a/src/pages/api/websites/[websiteId]/sessions/stats.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { getRequestDateRange, getRequestFilters } from 'lib/request'; -import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getWebsiteSessionStats } from 'queries/analytics/sessions/getWebsiteSessionStats'; -import * as yup from 'yup'; - -export interface WebsiteSessionStatsRequestQuery { - websiteId: string; - startAt: number; - endAt: number; - url?: string; - referrer?: string; - title?: string; - query?: string; - event?: string; - host?: string; - os?: string; - browser?: string; - device?: string; - country?: string; - region?: string; - city?: string; - tag?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().required(), - endAt: yup.number().required(), - url: yup.string(), - referrer: yup.string(), - title: yup.string(), - query: yup.string(), - event: yup.string(), - host: yup.string(), - os: yup.string(), - browser: yup.string(), - device: yup.string(), - country: yup.string(), - region: yup.string(), - city: yup.string(), - tag: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const { startDate, endDate } = await getRequestDateRange(req); - - const filters = getRequestFilters(req); - - const metrics = await getWebsiteSessionStats(websiteId, { - ...filters, - startDate, - endDate, - }); - - const stats = Object.keys(metrics[0]).reduce((obj, key) => { - obj[key] = { - value: Number(metrics[0][key]) || 0, - }; - return obj; - }, {}); - - return ok(res, stats); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/sessions/weekly.ts b/src/pages/api/websites/[websiteId]/sessions/weekly.ts deleted file mode 100644 index b1c28c3f..00000000 --- a/src/pages/api/websites/[websiteId]/sessions/weekly.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as yup from 'yup'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { pageInfo } from 'lib/schema'; -import { getWebsiteSessionsWeekly } from 'queries'; -import { TimezoneTest } from 'lib/yup'; - -export interface ReportsRequestQuery extends PageParams { - websiteId: string; - timezone?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().integer().required(), - endAt: yup.number().integer().min(yup.ref('startAt')).required(), - timezone: TimezoneTest, - ...pageInfo, - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, startAt, endAt, timezone } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const startDate = new Date(+startAt); - const endDate = new Date(+endAt); - - const data = await getWebsiteSessionsWeekly(websiteId, { startDate, endDate, timezone }); - - return ok(res, data); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/stats.ts b/src/pages/api/websites/[websiteId]/stats.ts deleted file mode 100644 index dfc9198d..00000000 --- a/src/pages/api/websites/[websiteId]/stats.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as yup from 'yup'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types'; -import { getRequestFilters, getRequestDateRange } from 'lib/request'; -import { getWebsiteStats } from 'queries'; -import { getCompareDate } from 'lib/date'; - -export interface WebsiteStatsRequestQuery { - websiteId: string; - startAt: number; - endAt: number; - url?: string; - referrer?: string; - title?: string; - query?: string; - event?: string; - host?: string; - os?: string; - browser?: string; - device?: string; - country?: string; - region?: string; - city?: string; - tag?: string; - compare?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - startAt: yup.number().required(), - endAt: yup.number().required(), - url: yup.string(), - referrer: yup.string(), - title: yup.string(), - query: yup.string(), - event: yup.string(), - host: yup.string(), - os: yup.string(), - browser: yup.string(), - device: yup.string(), - country: yup.string(), - region: yup.string(), - city: yup.string(), - tag: yup.string(), - compare: yup.string(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, compare } = req.query; - - if (req.method === 'GET') { - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const { startDate, endDate } = await getRequestDateRange(req); - const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate( - compare, - startDate, - endDate, - ); - - const filters = getRequestFilters(req); - - 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 ok(res, stats); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/transfer.ts b/src/pages/api/websites/[websiteId]/transfer.ts deleted file mode 100644 index 56cf6bac..00000000 --- a/src/pages/api/websites/[websiteId]/transfer.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextApiRequestQueryBody } from 'lib/types'; -import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { updateWebsite } from 'queries'; -import * as yup from 'yup'; - -export interface WebsiteTransferRequestQuery { - websiteId: string; -} - -export interface WebsiteTransferRequestBody { - userId?: string; - teamId?: string; -} - -const schema = { - POST: yup.object().shape({ - websiteId: yup.string().uuid().required(), - userId: yup.string().uuid(), - teamId: yup.string().uuid(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId } = req.query; - const { userId, teamId } = req.body; - - if (req.method === 'POST') { - if (userId) { - if (!(await canTransferWebsiteToUser(req.auth, websiteId, userId))) { - return unauthorized(res); - } - - const website = await updateWebsite(websiteId, { - userId, - teamId: null, - }); - - return ok(res, website); - } else if (teamId) { - if (!(await canTransferWebsiteToTeam(req.auth, websiteId, teamId))) { - return unauthorized(res); - } - - const website = await updateWebsite(websiteId, { - userId: null, - teamId, - }); - - return ok(res, website); - } - - return badRequest(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/[websiteId]/values.ts b/src/pages/api/websites/[websiteId]/values.ts deleted file mode 100644 index 53d717a5..00000000 --- a/src/pages/api/websites/[websiteId]/values.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { NextApiRequestQueryBody } from 'lib/types'; -import { canViewWebsite } from 'lib/auth'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiResponse } from 'next'; -import { - badRequest, - methodNotAllowed, - ok, - safeDecodeURIComponent, - unauthorized, -} from 'next-basics'; -import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; -import { getValues } from 'queries'; -import { getRequestDateRange } from 'lib/request'; -import * as yup from 'yup'; - -export interface ValuesRequestQuery { - websiteId: string; - type: string; - startAt: number; - endAt: number; - search?: string; -} - -const schema = { - GET: yup.object().shape({ - websiteId: yup.string().uuid().required(), - type: yup.string().required(), - startAt: yup.number().required(), - endAt: yup.number().required(), - search: yup.string(), - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { websiteId, type, search } = req.query; - const { startDate, endDate } = await getRequestDateRange(req); - - if (req.method === 'GET') { - if (!SESSION_COLUMNS.includes(type as string) && !EVENT_COLUMNS.includes(type as string)) { - return badRequest(res); - } - - if (!(await canViewWebsite(req.auth, websiteId))) { - return unauthorized(res); - } - - const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search); - - return ok( - res, - values - .map(({ value }) => safeDecodeURIComponent(value)) - .filter(n => n) - .sort(), - ); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/websites/index.ts b/src/pages/api/websites/index.ts deleted file mode 100644 index c5eb7200..00000000 --- a/src/pages/api/websites/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { canCreateTeamWebsite, canCreateWebsite } from 'lib/auth'; -import { uuid } from 'lib/crypto'; -import { useAuth, useCors, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody, PageParams } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createWebsite } from 'queries'; -import userWebsitesRoute from 'pages/api/users/[userId]/websites'; -import * as yup from 'yup'; -import { pageInfo } from 'lib/schema'; - -export interface WebsitesRequestQuery extends PageParams {} - -export interface WebsitesRequestBody { - name: string; - domain: string; - shareId: string; - teamId: string; -} - -const schema = { - GET: yup.object().shape({ - ...pageInfo, - }), - POST: yup.object().shape({ - name: yup.string().max(100).required(), - domain: yup.string().max(500).required(), - shareId: yup.string().max(50).nullable(), - teamId: yup.string().nullable(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useCors(req, res); - await useAuth(req, res); - await useValidate(schema, req, res); - - const { - user: { id: userId }, - } = req.auth; - - if (req.method === 'GET') { - if (!req.query.userId) { - req.query.userId = userId; - } - - return userWebsitesRoute(req, res); - } - - if (req.method === 'POST') { - const { name, domain, shareId, teamId } = req.body; - - if ( - (teamId && !(await canCreateTeamWebsite(req.auth, teamId))) || - !(await canCreateWebsite(req.auth)) - ) { - return unauthorized(res); - } - - const data: any = { - id: uuid(), - createdBy: userId, - name, - domain, - shareId, - teamId, - }; - - if (!teamId) { - data.userId = userId; - } - - const website = await createWebsite(data); - - return ok(res, website); - } - - return methodNotAllowed(res); -}; diff --git a/src/queries/analytics/getActiveVisitors.ts b/src/queries/analytics/getActiveVisitors.ts index c59a265a..d5607e27 100644 --- a/src/queries/analytics/getActiveVisitors.ts +++ b/src/queries/analytics/getActiveVisitors.ts @@ -15,7 +15,7 @@ async function relationalQuery(websiteId: string) { const result = await rawQuery( ` - select count(distinct session_id) x + select count(distinct session_id) as visitors from website_event where website_id = {{websiteId::uuid}} and created_at >= {{startDate}} @@ -32,7 +32,7 @@ async function clickhouseQuery(websiteId: string): Promise<{ x: number }> { const result = await rawQuery( ` select - count(distinct session_id) x + count(distinct session_id) as "visitors" from website_event where website_id = {websiteId:UUID} and created_at >= {startDate:DateTime64} diff --git a/src/queries/analytics/getValues.ts b/src/queries/analytics/getValues.ts index f303faff..f98cca3a 100644 --- a/src/queries/analytics/getValues.ts +++ b/src/queries/analytics/getValues.ts @@ -42,7 +42,7 @@ async function relationalQuery( return rawQuery( ` - select ${column} as "value", count(*) + select ${column} as "value", count(*) as "count" from website_event inner join session on session.session_id = website_event.session_id @@ -98,7 +98,7 @@ async function clickhouseQuery( return rawQuery( ` - select ${column} as value, count(*) + select ${column} as "value", count(*) as "count" from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} diff --git a/src/queries/analytics/pageviews/getPageviewMetrics.ts b/src/queries/analytics/pageviews/getPageviewMetrics.ts index f734b1dd..b356708e 100644 --- a/src/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/src/queries/analytics/pageviews/getPageviewMetrics.ts @@ -5,7 +5,13 @@ import prisma from 'lib/prisma'; import { QueryFilters } from 'lib/types'; export async function getPageviewMetrics( - ...args: [websiteId: string, type: string, filters: QueryFilters, limit?: number, offset?: number] + ...args: [ + websiteId: string, + type: string, + filters: QueryFilters, + limit?: number | string, + offset?: number | string, + ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -17,8 +23,8 @@ async function relationalQuery( websiteId: string, type: string, filters: QueryFilters, - limit: number = 500, - offset: number = 0, + limit: number | string = 500, + offset: number | string = 0, ) { const column = FILTER_COLUMNS[type] || type; const { rawQuery, parseFilters } = prisma; @@ -80,8 +86,8 @@ async function clickhouseQuery( websiteId: string, type: string, filters: QueryFilters, - limit: number = 500, - offset: number = 0, + limit: number | string = 500, + offset: number | string = 0, ): Promise<{ x: string; y: number }[]> { const column = FILTER_COLUMNS[type] || type; const { rawQuery, parseFilters } = clickhouse; diff --git a/src/queries/analytics/sessions/getSessionMetrics.ts b/src/queries/analytics/sessions/getSessionMetrics.ts index bb8bc4c5..0e8ebedf 100644 --- a/src/queries/analytics/sessions/getSessionMetrics.ts +++ b/src/queries/analytics/sessions/getSessionMetrics.ts @@ -5,7 +5,13 @@ import prisma from 'lib/prisma'; import { QueryFilters } from 'lib/types'; export async function getSessionMetrics( - ...args: [websiteId: string, type: string, filters: QueryFilters, limit?: number, offset?: number] + ...args: [ + websiteId: string, + type: string, + filters: QueryFilters, + limit?: number | string, + offset?: number | string, + ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -17,8 +23,8 @@ async function relationalQuery( websiteId: string, type: string, filters: QueryFilters, - limit: number = 500, - offset: number = 0, + limit: number | string = 500, + offset: number | string = 0, ) { const column = FILTER_COLUMNS[type] || type; const { parseFilters, rawQuery } = prisma; @@ -60,8 +66,8 @@ async function clickhouseQuery( websiteId: string, type: string, filters: QueryFilters, - limit: number = 500, - offset: number = 0, + limit: number | string = 500, + offset: number | string = 0, ): Promise<{ x: string; y: number }[]> { const column = FILTER_COLUMNS[type] || type; const { parseFilters, rawQuery } = clickhouse; diff --git a/src/queries/index.ts b/src/queries/index.ts index 8c7e564a..63cebb45 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -27,6 +27,7 @@ export * from './analytics/sessions/getSessionDataProperties'; export * from './analytics/sessions/getSessionDataValues'; export * from './analytics/sessions/getSessionMetrics'; export * from './analytics/sessions/getWebsiteSessions'; +export * from './analytics/sessions/getWebsiteSessionStats'; export * from './analytics/sessions/getWebsiteSessionsWeekly'; export * from './analytics/sessions/getSessionActivity'; export * from './analytics/sessions/getSessionStats'; diff --git a/src/queries/prisma/report.ts b/src/queries/prisma/report.ts index a0e6364c..51e7ddc2 100644 --- a/src/queries/prisma/report.ts +++ b/src/queries/prisma/report.ts @@ -19,11 +19,11 @@ export async function getReports( criteria: ReportFindManyArgs, pageParams: PageParams = {}, ): Promise> { - const { query } = pageParams; + const { search } = pageParams; const where: Prisma.ReportWhereInput = { ...criteria.where, - ...prisma.getSearchParameters(query, [ + ...prisma.getSearchParameters(search, [ { name: 'contains' }, { description: 'contains' }, { type: 'contains' }, diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index dc1ec438..1477a835 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -30,11 +30,11 @@ export async function getWebsites( criteria: WebsiteFindManyArgs, pageParams: PageParams, ): Promise> { - const { query } = pageParams; + const { search } = pageParams; const where: Prisma.WebsiteWhereInput = { ...criteria.where, - ...prisma.getSearchParameters(query, [ + ...prisma.getSearchParameters(search, [ { name: 'contains', }, diff --git a/yarn.lock b/yarn.lock index 9fe7d6e2..1aab5657 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1117,7 +1117,7 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.15.4", "@babel/runtime@^7.21.0", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.21.0", "@babel/runtime@^7.8.4": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg== @@ -2980,11 +2980,6 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/lodash@^4.14.175": - version "4.14.200" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.200.tgz#435b6035c7eba9cdf1e039af8212c9e9281e7149" - integrity sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q== - "@types/minimatch@*": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" @@ -7617,11 +7612,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" @@ -8054,11 +8044,6 @@ ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoclone@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" - integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== - nanoid@^3.3.6, nanoid@^3.3.7: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -9176,11 +9161,6 @@ prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -property-expr@^2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" - integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== - proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" @@ -9238,10 +9218,10 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -react-basics@^0.125.0: - version "0.125.0" - resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.125.0.tgz#6baf3fea503fb4475f51877efa05d1a734b232c6" - integrity sha512-8swjTaKfenwb+NunwzQo16V+dCA/38Kd+PSYWpBFyNmlFzs3Ax2ZgnysxDhW9IgfFr4wR6/0gzD3S31WzXq6Kw== +react-basics@^0.126.0: + version "0.126.0" + resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.126.0.tgz#44e7f5e5ab9d411e91e697dd39c6cb53b6222ae0" + integrity sha512-TQtNZMeH5FtJjYxSN72rBmZWlIcs9jK3oVSCUUxfZq9LnFdoFSagTLCrihs3YCnX8vZEJXaJHQsp7lKEfyH5sw== dependencies: "@react-spring/web" "^9.7.3" classnames "^2.3.1" @@ -10130,7 +10110,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10225,7 +10214,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10564,11 +10560,6 @@ topojson-client@^3.1.0: dependencies: commander "2" -toposort@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" - integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== - tough-cookie@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.0.0.tgz#6b6518e2b5c070cf742d872ee0f4f92d69eac1af" @@ -11016,7 +11007,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11034,6 +11025,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -11160,18 +11160,10 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yup@^0.32.11: - version "0.32.11" - resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" - integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== - dependencies: - "@babel/runtime" "^7.15.4" - "@types/lodash" "^4.14.175" - lodash "^4.17.21" - lodash-es "^4.17.21" - nanoclone "^0.2.1" - property-expr "^2.0.4" - toposort "^2.0.2" +zod@^3.24.1: + version "3.24.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" + integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== zustand@^4.5.5: version "4.5.6"