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