diff --git a/next-env.d.ts b/next-env.d.ts index 3cd7048e..725dd6f2 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/next.config.js b/next.config.js index e4e55ab7..99791dff 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,16 @@ const trackerHeaders = [ ]; const headers = [ + { + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Credentials', value: 'true' }, + { 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 3c78fffb..1b81c514 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", @@ -119,6 +119,7 @@ "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/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/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/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts new file mode 100644 index 00000000..41cd6bcf --- /dev/null +++ b/src/app/api/users/[userId]/route.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { canUpdateUser, canViewUser, checkAuth } from 'lib/auth'; +import { getUser, getUserByUsername, updateUser } from 'queries'; +import { json, unauthorized, badRequest } from 'lib/response'; +import { hashPassword } from 'next-basics'; +import { checkRequest } from 'lib/request'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { userId } = await params; + const auth = await checkAuth(request); + + if (!auth || !(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 { body, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const { userId } = await params; + const auth = await checkAuth(request); + + if (!auth || !(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); +} 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..0cdccdaf --- /dev/null +++ b/src/app/api/users/[userId]/teams/route.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { pagingParams } from 'lib/schema'; +import { getUserTeams } from 'queries'; +import { checkAuth } from 'lib/auth'; +import { unauthorized, badRequest, json } from 'lib/response'; +import { checkRequest } from 'lib/request'; + +const schema = z.object({ + ...pagingParams, +}); + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { userId } = await params; + + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + + if (!auth || (!auth.user.isAdmin && (!userId || auth.user.id !== userId))) { + 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..177d3c35 --- /dev/null +++ b/src/app/api/users/[userId]/usage/route.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { json, unauthorized, badRequest } from 'lib/response'; +import { getAllUserWebsitesIncludingTeamOwner } from 'queries/prisma/website'; +import { getEventUsage } from 'queries/analytics/events/getEventUsage'; +import { getEventDataUsage } from 'queries/analytics/events/getEventDataUsage'; +import { checkAuth } from 'lib/auth'; +import { checkRequest } from 'lib/request'; + +const schema = z.object({ + startAt: z.coerce.number(), + endAt: z.coerce.number(), +}); + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + + if (!auth || !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..61783cd6 --- /dev/null +++ b/src/app/api/users/[userId]/websites/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { unauthorized, json, badRequest } from 'lib/response'; +import { getUserWebsites } from 'queries/prisma/website'; +import { pagingParams } from 'lib/schema'; +import { checkRequest } from 'lib/request'; +import { checkAuth } from 'lib/auth'; + +const schema = z.object({ + ...pagingParams, +}); + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const { userId } = await params; + const auth = await checkAuth(request); + + if (!auth || (!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..870e6181 --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { hashPassword } from 'next-basics'; +import { canCreateUser, checkAuth } from 'lib/auth'; +import { ROLES } from 'lib/constants'; +import { uuid } from 'lib/crypto'; +import { checkRequest } from 'lib/request'; +import { unauthorized, json, badRequest } from 'lib/response'; +import { createUser, getUserByUsername } from 'queries'; + +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), +}); + +export async function POST(request: Request) { + const { body, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + + if (!auth || !(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..22bd1999 --- /dev/null +++ b/src/app/api/websites/[websiteId]/active/route.ts @@ -0,0 +1,24 @@ +import { canViewWebsite, checkAuth } from 'lib/auth'; +import { json, unauthorized } from 'lib/response'; +import { getActiveVisitors } from 'queries'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const auth = await checkAuth(request); + + if (!auth) { + return unauthorized(); + } + + 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..70460bd6 --- /dev/null +++ b/src/app/api/websites/[websiteId]/daterange/route.ts @@ -0,0 +1,19 @@ +import { canViewWebsite, checkAuth } from 'lib/auth'; +import { getWebsiteDateRange } from 'queries'; +import { json, unauthorized } from 'lib/response'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const auth = await checkAuth(request); + const { websiteId } = await params; + + if (!auth || !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await getWebsiteDateRange(websiteId); + + return json(result); +} 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..3edc0d88 --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/route.ts @@ -0,0 +1,97 @@ +import { canViewWebsite, checkAuth } from 'lib/auth'; +import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants'; +import { getRequestFilters, getRequestDateRange, checkRequest } from 'lib/request'; +import { getPageviewMetrics, getSessionMetrics } from 'queries'; + +import { z } from 'zod'; +import { json, unauthorized, badRequest } from 'lib/response'; + +const schema = z.object({ + type: z.string(), + startAt: z.coerce.number(), + endAt: z.coerce.number(), + // optional + url: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + query: z.string().optional(), + host: 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(), + language: z.string().optional(), + event: z.string().optional(), + limit: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + search: z.string().optional(), + tag: z.string().optional(), +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { type, limit, offset, search } = query; + + if (!auth || !(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..7ba5b100 --- /dev/null +++ b/src/app/api/websites/[websiteId]/pageviews/route.ts @@ -0,0 +1,96 @@ +import { z } from 'zod'; +import { canViewWebsite, checkAuth } from 'lib/auth'; +import { getRequestFilters, getRequestDateRange, checkRequest } from 'lib/request'; +import { unit, timezone } from 'lib/schema'; +import { getCompareDate } from 'lib/date'; +import { badRequest, unauthorized, json } from 'lib/response'; +import { getPageviewStats, getSessionStats } from 'queries'; + +const schema = z.object({ + startAt: z.coerce.number(), + endAt: z.coerce.number(), + unit, + timezone, + url: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + host: 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(), + compare: z.string().optional(), +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { timezone, compare } = query; + + if (!auth || !(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..f4fc641f --- /dev/null +++ b/src/app/api/websites/[websiteId]/reports/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { canViewWebsite, checkAuth } from 'lib/auth'; +import { getWebsiteReports } from 'queries'; +import { pagingParams } from 'lib/schema'; +import { checkRequest } from 'lib/request'; +import { badRequest, unauthorized, json } from 'lib/response'; + +const schema = z.object({ + ...pagingParams, +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { page, pageSize, search } = query; + + if (!auth || !(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..ae2131e8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/reset/route.ts @@ -0,0 +1,19 @@ +import { canUpdateWebsite, checkAuth } from 'lib/auth'; +import { resetWebsite } from 'queries'; +import { unauthorized, ok } from 'lib/response'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const auth = await checkAuth(request); + const { websiteId } = await params; + + if (!auth || !(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..02bb00f8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/route.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { canUpdateWebsite, canDeleteWebsite, checkAuth, canViewWebsite } from 'lib/auth'; +import { SHARE_ID_REGEX } from 'lib/constants'; +import { checkRequest } from 'lib/request'; +import { ok, json, badRequest, unauthorized, serverError } from 'lib/response'; +import { deleteWebsite, getWebsite, updateWebsite } from 'queries'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const auth = await checkAuth(request); + + if (!auth) { + return unauthorized(); + } + + 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 { body, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { name, domain, shareId } = body; + + if (!auth || !(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 = await checkAuth(request); + + if (!auth) { + return unauthorized(); + } + + const { websiteId } = await params; + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteWebsite(websiteId); + + return ok(); +} 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..1c96a74f --- /dev/null +++ b/src/app/api/websites/[websiteId]/stats/route.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { checkRequest, getRequestDateRange, getRequestFilters } from 'lib/request'; +import { badRequest, unauthorized, json } from 'lib/response'; +import { checkAuth, canViewWebsite } from 'lib/auth'; +import { getCompareDate } from 'lib/date'; +import { getWebsiteStats } from 'queries'; + +const schema = z.object({ + startAt: z.coerce.number(), + endAt: z.coerce.number(), + // optional + url: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + query: z.string().optional(), + event: z.string().optional(), + host: 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(), + compare: z.string().optional(), +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { compare } = query; + + if (!auth || !(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..d97fe0f8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/transfer/route.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { canTransferWebsiteToTeam, canTransferWebsiteToUser, checkAuth } from 'lib/auth'; +import { updateWebsite } from 'queries'; +import { checkRequest } from 'lib/request'; +import { badRequest, unauthorized, json } from 'lib/response'; + +const schema = z.object({ + userId: z.string().uuid().optional(), + teamId: z.string().uuid().optional(), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { body, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { userId, teamId } = body; + + if (!auth) { + return unauthorized(); + } else 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); + } +} 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..1a4967b8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/values/route.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { canViewWebsite, checkAuth } from 'lib/auth'; +import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; +import { getValues } from 'queries'; +import { checkRequest, getRequestDateRange } from 'lib/request'; +import { badRequest, json, unauthorized } from 'lib/response'; + +const schema = z.object({ + type: z.string(), + startAt: z.coerce.number(), + endAt: z.coerce.number(), + search: z.string().optional(), +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + const { websiteId } = await params; + const { type, search } = query; + const { startDate, endDate } = await getRequestDateRange(request); + + if (!auth || !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) { + return badRequest(); + } + + 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..6bb1e476 --- /dev/null +++ b/src/app/api/websites/route.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { canCreateTeamWebsite, canCreateWebsite, checkAuth } from 'lib/auth'; +import { json, badRequest, unauthorized } from 'lib/response'; +import { uuid } from 'lib/crypto'; +import { checkRequest } 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 { query, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + + if (!auth) { + return unauthorized(); + } + + const websites = await getUserWebsites(auth.user.userId, 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(), + teamId: z.string().nullable(), + }); + + const { body, error } = await checkRequest(request, schema); + + if (error) { + return badRequest(error); + } + + const auth = await checkAuth(request); + + if (!auth) { + return unauthorized(); + } + + 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.userId, + name, + domain, + shareId, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.userId; + } + + 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/usePagedQuery.ts b/src/components/hooks/usePagedQuery.ts index 19471432..e62ed0e1 100644 --- a/src/components/hooks/usePagedQuery.ts +++ b/src/components/hooks/usePagedQuery.ts @@ -11,7 +11,7 @@ export function usePagedQuery({ }: Omit & { queryFn: (params?: object) => any }): PagedQueryResult { const { query: queryParams } = useNavigation(); const [params, setParams] = useState({ - query: '', + search: '', page: +queryParams.page || 1, }); 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/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/request.ts b/src/lib/request.ts index 5e2be2fe..5eb1b477 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,10 +1,28 @@ -import { NextApiRequest } from 'next'; +import { ZodObject } from 'zod'; import { getAllowedUnits, getMinimumUnit } from './date'; import { getWebsiteDateRange } from '../queries'; import { FILTER_COLUMNS } from 'lib/constants'; -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 null; + } +} + +export async function checkRequest(request: Request, schema: ZodObject) { + const url = new URL(request.url); + const query = Object.fromEntries(url.searchParams); + const body = await getJsonBody(request); + + const result = schema.safeParse(request.method === 'GET' ? query : body); + + return { query, body, error: result.error }; +} + +export async function getRequestDateRange(query: Record) { + const { websiteId, startAt, endAt, unit } = query; // All-time if (+startAt === 0 && +endAt === 1) { @@ -31,9 +49,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..da9e3f89 --- /dev/null +++ b/src/lib/response.ts @@ -0,0 +1,21 @@ +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() { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); +} + +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..9153d0f9 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -1,4 +1,7 @@ +import { z } from 'zod'; import * as yup from 'yup'; +import { isValidTimezone } from 'lib/date'; +import { UNIT_TYPES } from './constants'; export const dateRange = { startAt: yup.number().integer().required(), @@ -11,3 +14,18 @@ export const pageInfo = { pageSize: yup.number().integer().positive().min(1).max(200), orderBy: yup.string(), }; + +export const pagingParams = { + page: z.coerce.number().int().positive(), + pageSize: z.coerce.number().int().positive(), + orderBy: z.string().optional(), + query: z.string().optional(), +}; + +export const timezone = z.string().refine(value => isValidTimezone(value), { + message: 'Invalid timezone', +}); + +export const unit = z.string().refine(value => UNIT_TYPES.includes(value), { + message: 'Invalid unit', +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index d7a12068..70c2aae6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,7 +25,7 @@ export type KafkaTopic = ObjectValues; export type ReportType = ObjectValues; export interface PageParams { - query?: string; + search?: string; page?: number; pageSize?: number; orderBy?: string; @@ -43,7 +43,7 @@ export interface PageResult { export interface PagedQueryResult { result: PageResult; - query: any; + search: any; params: PageParams; setParams: Dispatch>; } diff --git a/src/pages/api/config.ts b/src/pages/api/_config.ts similarity index 100% rename from src/pages/api/config.ts rename to src/pages/api/_config.ts diff --git a/src/pages/api/version.ts b/src/pages/api/_version.ts similarity index 100% rename from src/pages/api/version.ts rename to src/pages/api/_version.ts 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/teams.ts b/src/pages/api/me/teams.ts index 3b88689d..e40e548c 100644 --- a/src/pages/api/me/teams.ts +++ b/src/pages/api/me/teams.ts @@ -3,7 +3,7 @@ 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 userTeamsRoute from 'pages/api/users/[userId]/_teams'; import * as yup from 'yup'; const schema = { diff --git a/src/pages/api/me/websites.ts b/src/pages/api/me/websites.ts index 48800f90..5c3030e6 100644 --- a/src/pages/api/me/websites.ts +++ b/src/pages/api/me/websites.ts @@ -3,7 +3,7 @@ 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 userWebsitesRoute from 'pages/api/users/[userId]/_websites'; import * as yup from 'yup'; const schema = { diff --git a/src/pages/api/users/[userId]/index.ts b/src/pages/api/users/[userId]/_index.ts similarity index 100% rename from src/pages/api/users/[userId]/index.ts rename to src/pages/api/users/[userId]/_index.ts diff --git a/src/pages/api/users/[userId]/teams.ts b/src/pages/api/users/[userId]/_teams.ts similarity index 100% rename from src/pages/api/users/[userId]/teams.ts rename to src/pages/api/users/[userId]/_teams.ts diff --git a/src/pages/api/users/[userId]/usage.ts b/src/pages/api/users/[userId]/_usage.ts similarity index 100% rename from src/pages/api/users/[userId]/usage.ts rename to src/pages/api/users/[userId]/_usage.ts diff --git a/src/pages/api/users/[userId]/websites.ts b/src/pages/api/users/[userId]/_websites.ts similarity index 100% rename from src/pages/api/users/[userId]/websites.ts rename to src/pages/api/users/[userId]/_websites.ts diff --git a/src/pages/api/users/index.ts b/src/pages/api/users/_index.ts similarity index 100% rename from src/pages/api/users/index.ts rename to src/pages/api/users/_index.ts diff --git a/src/pages/api/websites/[websiteId]/active.ts b/src/pages/api/websites/[websiteId]/_active.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/active.ts rename to src/pages/api/websites/[websiteId]/_active.ts diff --git a/src/pages/api/websites/[websiteId]/daterange.ts b/src/pages/api/websites/[websiteId]/_daterange.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/daterange.ts rename to src/pages/api/websites/[websiteId]/_daterange.ts diff --git a/src/pages/api/websites/[websiteId]/index.ts b/src/pages/api/websites/[websiteId]/_index.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/index.ts rename to src/pages/api/websites/[websiteId]/_index.ts diff --git a/src/pages/api/websites/[websiteId]/metrics.ts b/src/pages/api/websites/[websiteId]/_metrics.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/metrics.ts rename to src/pages/api/websites/[websiteId]/_metrics.ts diff --git a/src/pages/api/websites/[websiteId]/pageviews.ts b/src/pages/api/websites/[websiteId]/_pageviews.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/pageviews.ts rename to src/pages/api/websites/[websiteId]/_pageviews.ts diff --git a/src/pages/api/websites/[websiteId]/reports.ts b/src/pages/api/websites/[websiteId]/_reports.ts similarity index 94% rename from src/pages/api/websites/[websiteId]/reports.ts rename to src/pages/api/websites/[websiteId]/_reports.ts index 72e5b0f2..86260634 100644 --- a/src/pages/api/websites/[websiteId]/reports.ts +++ b/src/pages/api/websites/[websiteId]/_reports.ts @@ -33,12 +33,12 @@ export default async ( return unauthorized(res); } - const { page, query, pageSize } = req.query; + const { page, search, pageSize } = req.query; const data = await getWebsiteReports(websiteId, { page, pageSize, - query, + search, }); return ok(res, data); diff --git a/src/pages/api/websites/[websiteId]/reset.ts b/src/pages/api/websites/[websiteId]/_reset.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/reset.ts rename to src/pages/api/websites/[websiteId]/_reset.ts diff --git a/src/pages/api/websites/[websiteId]/stats.ts b/src/pages/api/websites/[websiteId]/_stats.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/stats.ts rename to src/pages/api/websites/[websiteId]/_stats.ts diff --git a/src/pages/api/websites/[websiteId]/transfer.ts b/src/pages/api/websites/[websiteId]/_transfer.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/transfer.ts rename to src/pages/api/websites/[websiteId]/_transfer.ts diff --git a/src/pages/api/websites/[websiteId]/values.ts b/src/pages/api/websites/[websiteId]/_values.ts similarity index 100% rename from src/pages/api/websites/[websiteId]/values.ts rename to src/pages/api/websites/[websiteId]/_values.ts diff --git a/src/pages/api/websites/index.ts b/src/pages/api/websites/_index.ts similarity index 96% rename from src/pages/api/websites/index.ts rename to src/pages/api/websites/_index.ts index c5eb7200..483b77cd 100644 --- a/src/pages/api/websites/index.ts +++ b/src/pages/api/websites/_index.ts @@ -5,7 +5,7 @@ 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 userWebsitesRoute from 'pages/api/users/[userId]/_websites'; import * as yup from 'yup'; import { pageInfo } from 'lib/schema'; 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/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/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 8b364379..fb739910 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11210,6 +11210,11 @@ yup@^0.32.11: 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" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.6.tgz#6857d52af44874a79fb3408c9473f78367255c96"