Merge branch 'dev' into boards
Some checks failed
Node.js CI / build (push) Has been cancelled

# Conflicts:
#	.gitignore
#	package.json
#	pnpm-lock.yaml
#	prisma/migrations/16_boards/migration.sql
#	prisma/schema.prisma
#	src/app/(main)/MobileNav.tsx
#	src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
#	src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
#	src/components/common/SideMenu.tsx
#	src/lib/types.ts
This commit is contained in:
Mike Cao 2026-02-05 20:05:25 -08:00
commit c3e0290e65
150 changed files with 3028 additions and 787 deletions

View file

@ -1,7 +1,14 @@
import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { ok } from '@/lib/response';
export async function POST(request: Request) {
const { error } = await parseRequest(request);
if (error) {
return error();
}
if (redis.enabled) {
const token = request.headers.get('authorization')?.split(' ')?.[1];

View file

@ -1,7 +1,7 @@
import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { json, serverError } from '@/lib/response';
export async function POST(request: Request) {
const { auth, error } = await parseRequest(request);
@ -10,9 +10,13 @@ export async function POST(request: Request) {
return error();
}
if (redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
if (!redis.enabled) {
return serverError({
message: 'Redis is disabled',
});
}
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
}

View file

@ -12,11 +12,16 @@ export async function POST(request: Request) {
}
const { websiteId, parameters, filters } = body;
const { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId);
const data = await getJourney(websiteId, parameters, queryFilters);

View file

@ -1,10 +1,10 @@
import { startOfHour, startOfMonth } from 'date-fns';
import { startOfHour } 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 { getSalt, hash, secret, uuid } from '@/lib/crypto';
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { createToken, parseToken } from '@/lib/jwt';
import { fetchWebsite } from '@/lib/load';
@ -130,7 +130,8 @@ export async function POST(request: Request) {
const createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
const now = Math.floor(Date.now() / 1000);
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
const saltRotation = process.env.SALT_ROTATION || 'month';
const sessionSalt = getSalt(saltRotation, createdAt);
const visitSalt = hash(startOfHour(createdAt).toUTCString());
const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt);

View file

@ -1,7 +1,47 @@
import { ROLES } from '@/lib/constants';
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response';
import { getShareByCode } from '@/queries/prisma';
import type { WhiteLabel } from '@/lib/types';
import { getShareByCode, getWebsite } from '@/queries/prisma';
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
if (website.userId) {
return website.userId;
}
if (website.teamId) {
const teamOwner = await prisma.client.teamUser.findFirst({
where: {
teamId: website.teamId,
role: ROLES.teamOwner,
},
select: {
userId: true,
},
});
return teamOwner?.userId || null;
}
return null;
}
async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
if (!redis.enabled) {
return null;
}
const data = await redis.client.get(`white-label:${accountId}`);
if (data) {
return data as WhiteLabel;
}
return null;
}
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
@ -12,8 +52,25 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
return notFound();
}
const data = { shareId: share.id };
const token = createToken(data, secret());
const website = await getWebsite(share.entityId);
return json({ ...data, token });
const data: Record<string, any> = {
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
};
data.token = createToken(data, secret());
const accountId = await getAccountId(website);
if (accountId) {
const whiteLabel = await getWhiteLabel(accountId);
if (whiteLabel) {
data.whiteLabel = whiteLabel;
}
}
return json(data);
}

View file

@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ shar
export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const schema = z.object({
name: z.string().max(200),
slug: z.string().max(100),
parameters: anyObjectParam,
});
@ -36,7 +37,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha
}
const { shareId } = await params;
const { slug, parameters } = body;
const { name, slug, parameters } = body;
const share = await getShare(shareId);
@ -49,6 +50,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ sha
}
const result = await updateShare(shareId, {
name,
slug,
parameters,
} as any);

View file

@ -1,5 +1,6 @@
import z from 'zod';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema';
@ -10,7 +11,8 @@ export async function POST(request: Request) {
const schema = z.object({
entityId: z.uuid(),
shareType: z.coerce.number().int(),
slug: z.string().max(100),
name: z.string().max(200),
slug: z.string().max(100).optional(),
parameters: anyObjectParam,
});
@ -20,7 +22,8 @@ export async function POST(request: Request) {
return error();
}
const { entityId, shareType, slug, parameters } = body;
const { entityId, shareType, name, slug, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateEntity(auth, entityId))) {
return unauthorized();
@ -30,8 +33,9 @@ export async function POST(request: Request) {
id: uuid(),
entityId,
shareType,
slug,
parameters,
name,
slug: slug || getRandomChars(16),
parameters: shareParameters,
});
return json(share);

View file

@ -28,6 +28,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const schema = z.object({
name: z.string().max(50),
ownerId: z.uuid().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized();
}
const { name } = body;
const { name, ownerId } = body;
const team = await createTeam(
{
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name,
accessCode: `team_${getRandomChars(16)}`,
},
auth.user.id,
ownerId ?? auth.user.id,
);
return json(team);

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response';
import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ 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(),
password: z.string().min(8).max(255).optional(),
role: userRoleParam.optional(),
});
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
const user = await getUser(userId);
if (!user) {
return notFound();
}
const data: any = {};
if (password) {

View file

@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canCreateUser } from '@/permissions';
import { createUser, getUserByUsername } from '@/queries/prisma';
@ -11,8 +12,8 @@ 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),
password: z.string().min(8).max(255),
role: userRoleParam,
});
const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams, filterParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats';
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 getWebsiteEventStats(websiteId, filters);
return json({ data });
}

View file

@ -1,9 +1,17 @@
import { z } from 'zod';
import { SHARE_ID_REGEX } from '@/lib/constants';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
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';
import {
createShare,
deleteSharesByEntityId,
deleteWebsite,
getShareByEntityId,
getWebsite,
updateWebsite,
} from '@/queries/prisma';
export async function GET(
request: Request,
@ -33,7 +41,7 @@ export async function POST(
const schema = z.object({
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
shareId: z.string().max(50).nullable().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -50,11 +58,29 @@ export async function POST(
}
try {
const website = await updateWebsite(websiteId, { name, domain, shareId });
const website = await updateWebsite(websiteId, { name, domain });
return Response.json(website);
if (shareId === null) {
await deleteSharesByEntityId(website.id);
}
const share = shareId
? await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name: website.name,
slug: shareId,
parameters: { overview: true, events: true },
})
: await getShareByEntityId(websiteId);
return json({
...website,
shareId: share?.slug ?? null,
});
} catch (e: any) {
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
if (e.message.toLowerCase().includes('unique constraint')) {
return badRequest({ message: 'That share ID is already taken.' });
}

View file

@ -2,7 +2,7 @@ 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 { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createSegment, getWebsiteSegments } from '@/queries/prisma';
@ -42,7 +42,7 @@ export async function POST(
const schema = z.object({
type: segmentTypeParam,
name: z.string().max(200),
parameters: anyObjectParam,
parameters: segmentParamSchema,
});
const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,77 @@
import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: 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 getSharesByEntityId(websiteId, {
page,
pageSize,
search,
});
return json(data);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
const slug = getRandomChars(16);
const share = await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name,
slug,
parameters: shareParameters,
});
return json(share);
}

View file

@ -31,9 +31,11 @@ export async function GET(
const data = await getWebsiteStats(websiteId, filters);
const compare = filters.compare ?? 'prev';
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate);
const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, {
...filters,

View file

@ -1,11 +1,12 @@
import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { fetchAccount } from '@/lib/load';
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 { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
const CLOUD_WEBSITE_LIMIT = 3;
@ -72,7 +73,6 @@ export async function POST(request: Request) {
createdBy: auth.user.id,
name,
domain,
shareId,
teamId,
};
@ -82,5 +82,19 @@ export async function POST(request: Request) {
const website = await createWebsite(data);
return json(website);
const share = shareId
? await createShare({
id: uuid(),
entityId: website.id,
shareType: ENTITY_TYPE.website,
name: website.name,
slug: shareId,
parameters: { overview: true, events: true },
})
: null;
return json({
...website,
shareId: share?.slug ?? null,
});
}