sdk changes

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-11 21:31:12 +01:00
parent 484a6b1d41
commit 447fa5896e
65 changed files with 9428 additions and 723 deletions

View File

@@ -198,22 +198,19 @@ export default function OverviewMetrics() {
return (
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<ReportRange
size="sm"
value={range}
onChange={(value) => setRange(value)}
/>
<div className="flex-wrap flex gap-2">
<LiveCounter initialCount={0} />
<OverviewFiltersButtons />
<div className="flex gap-2">
<ReportRange value={range} onChange={(value) => setRange(value)} />
<SheetTrigger asChild>
<Button size="sm" variant="cta" icon={FilterIcon}>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
</div>
<div className="flex gap-2">
<LiveCounter initialCount={0} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" icon={Globe2Icon}>
<Button icon={Globe2Icon} responsive>
Public
</Button>
</DropdownMenuTrigger>
@@ -236,6 +233,10 @@ export default function OverviewMetrics() {
</DropdownMenu>
</div>
</StickyBelowHeader>
<div className="p-4 flex gap-2 flex-wrap">
<OverviewFiltersButtons />
</div>
<div className="p-4 grid gap-4 grid-cols-6">
{reports.map((report, index) => (
<button
@@ -276,7 +277,7 @@ export default function OverviewMetrics() {
</div>
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters />
</SheetContent>
</Sheet>

View File

@@ -13,9 +13,8 @@ import { ProfileListItem } from './profile-list-item';
interface ListProfilesProps {
projectId: string;
organizationId: string;
}
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
export function ListProfiles({ projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const profilesQuery = api.profile.list.useQuery(

View File

@@ -13,10 +13,9 @@ export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} />
<ListProfiles projectId={projectId} />
</PageLayout>
);
}

View File

@@ -1,17 +1,20 @@
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import { Logo } from '@/components/Logo';
import { getOrganizationByProjectId } from '@/server/services/organization.service';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectById } from '@/server/services/project.service';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const project = await getProjectById(projectId);
const organization = await getOrganizationByProjectId(projectId);
const organization = await getOrganizationBySlug(organizationId);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-4xl mx-auto">

View File

@@ -1,6 +0,0 @@
import { authOptions } from '@/server/auth';
import NextAuth from 'next-auth/next';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -1,13 +1,8 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFiltersButtons() {
@@ -17,111 +12,166 @@ export function OverviewFiltersButtons() {
{options.referrer && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setReferrer(null)}
>
{options.referrer}
<span className="mr-1">Referrer is</span>
<strong>{options.referrer}</strong>
</Button>
)}
{options.device && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setDevice(null)}
>
{options.device}
<span className="mr-1">Device is</span>
<strong>{options.device}</strong>
</Button>
)}
{options.page && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setPage(null)}
>
{options.page}
<span className="mr-1">Page is</span>
<strong>{options.page}</strong>
</Button>
)}
{options.utmSource && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmSource(null)}
>
{options.utmSource}
<span className="mr-1">Utm Source is</span>
<strong>{options.utmSource}</strong>
</Button>
)}
{options.utmMedium && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmMedium(null)}
>
{options.utmMedium}
<span className="mr-1">Utm Medium is</span>
<strong>{options.utmMedium}</strong>
</Button>
)}
{options.utmCampaign && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmCampaign(null)}
>
{options.utmCampaign}
<span className="mr-1">Utm Campaign is</span>
<strong>{options.utmCampaign}</strong>
</Button>
)}
{options.utmTerm && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmTerm(null)}
>
{options.utmTerm}
<span className="mr-1">Utm Term is</span>
<strong>{options.utmTerm}</strong>
</Button>
)}
{options.utmContent && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmContent(null)}
>
{options.utmContent}
<span className="mr-1">Utm Content is</span>
<strong>{options.utmContent}</strong>
</Button>
)}
{options.country && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setCountry(null)}
>
{options.country}
<span className="mr-1">Country is</span>
<strong>{options.country}</strong>
</Button>
)}
{options.region && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setRegion(null)}
>
{options.region}
<span className="mr-1">Region is</span>
<strong>{options.region}</strong>
</Button>
)}
{options.city && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setCity(null)}
>
{options.city}
<span className="mr-1">City is</span>
<strong>{options.city}</strong>
</Button>
)}
{options.browser && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setBrowser(null)}
>
<span className="mr-1">Browser is</span>
<strong>{options.browser}</strong>
</Button>
)}
{options.browserVersion && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setBrowserVersion(null)}
>
<span className="mr-1">Browser Version is</span>
<strong>{options.browserVersion}</strong>
</Button>
)}
{options.os && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setOS(null)}
>
<span className="mr-1">OS is</span>
<strong>{options.os}</strong>
</Button>
)}
{options.osVersion && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setOSVersion(null)}
>
<span className="mr-1">OS Version is</span>
<strong>{options.osVersion}</strong>
</Button>
)}
</>

View File

@@ -11,8 +11,20 @@ import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopDevices() {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const {
filters,
interval,
range,
previous,
setBrowser,
setBrowserVersion,
browser,
browserVersion,
setOS,
setOSVersion,
os,
osVersion,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
@@ -180,19 +192,22 @@ export default function OverviewTopDevices() {
{...widget.chart}
previous={false}
onClick={(item) => {
// switch (widget.key) {
// case 'browser':
// setWidget('browser_version');
// // setCountry(item.name);
// break;
// case 'regions':
// setWidget('cities');
// setRegion(item.name);
// break;
// case 'cities':
// setCity(item.name);
// break;
// }
switch (widget.key) {
case 'browser':
setWidget('browser_version');
setBrowser(item.name);
break;
case 'browser_version':
setBrowserVersion(item.name);
break;
case 'os':
setWidget('os_version');
setOS(item.name);
break;
case 'os_version':
setOSVersion(item.name);
break;
}
}}
/>
</Suspense>

View File

@@ -1,7 +1,19 @@
'use client';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import { useThrottle } from '@/hooks/useThrottle';
import { cn } from '@/utils/cn';
import throttle from 'lodash.throttle';
import { ChevronsUpDownIcon } from 'lucide-react';
import { last } from 'ramda';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps } from '../Widget';
import { WidgetHead as WidgetHeadBase } from '../Widget';
@@ -14,14 +26,98 @@ export function WidgetHead({ className, ...props }: WidgetHeadProps) {
);
}
export function WidgetButtons({ className, ...props }: WidgetHeadProps) {
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(Children.count(children) - 1);
const gap = 8;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll(`button`)
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 }
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'flex gap-2 [&_button]:text-xs [&_button]:opacity-50 [&_button.active]:opacity-100',
'flex-1 justify-end transition-opacity flex flex-wrap [&_button]:text-xs [&_button]:opacity-50 [&_button]:whitespace-nowrap [&_button.active]:opacity-100',
className
)}
style={{ gap }}
{...props}
/>
>
{Children.map(children, (child, index) => {
return (
<div className={cn('flex', slice < index ? hidden : 'opacity-100')}>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-1 select-none',
sizes.current.length - 1 === slice ? hidden : 'opacity-50'
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -34,15 +34,12 @@ export function useOverviewOptions() {
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [page, setPage] = useQueryState(
'page',
parseAsString.withOptions(nuqsOptions)
);
// Sources
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
@@ -64,6 +61,7 @@ export function useOverviewOptions() {
parseAsString.withOptions(nuqsOptions)
);
// Geo
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
@@ -77,6 +75,28 @@ export function useOverviewOptions() {
parseAsString.withOptions(nuqsOptions)
);
//
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withOptions(nuqsOptions)
);
const [browserVersion, setBrowserVersion] = useQueryState(
'browser_version',
parseAsString.withOptions(nuqsOptions)
);
const [os, setOS] = useQueryState(
'os',
parseAsString.withOptions(nuqsOptions)
);
const [osVersion, setOSVersion] = useQueryState(
'os_version',
parseAsString.withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (referrer) {
@@ -178,6 +198,42 @@ export function useOverviewOptions() {
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser',
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browser_version',
operator: 'is',
name: 'browser_version',
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os',
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'os_version',
operator: 'is',
name: 'os_version',
value: [osVersion],
});
}
return filters;
}, [
referrer,
@@ -191,6 +247,10 @@ export function useOverviewOptions() {
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
]);
return {
@@ -202,8 +262,6 @@ export function useOverviewOptions() {
setMetric,
referrer,
setReferrer,
device,
setDevice,
page,
setPage,
@@ -230,5 +288,17 @@ export function useOverviewOptions() {
setRegion,
city,
setCity,
// Tech
device,
setDevice,
browser,
setBrowser,
browserVersion,
setBrowserVersion,
os,
setOS,
osVersion,
setOSVersion,
};
}

View File

@@ -1,10 +1,11 @@
import { createContext, memo, useContext, useMemo } from 'react';
import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@/types';
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: any) => void;
onClick?: (item: IChartSerie) => void;
}
type ChartProviderProps = {

View File

@@ -17,6 +17,7 @@ import {
} from '@/components/ui/tooltip';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { NOT_SET_VALUE } from '@/utils/constants';
import { getChartColor } from '@/utils/theme';
import { createColumnHelper } from '@tanstack/react-table';
@@ -51,10 +52,15 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
</div>
)}
{series.map((serie, index) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
return (
<div
key={serie.name}
className="py-2 flex flex-1 w-full gap-4 items-center"
className={cn(
'py-2 flex flex-1 w-full gap-4 items-center',
isClickable && 'cursor-pointer hover:bg-gray-100'
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div className="flex-1 break-all">{serie.name}</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">

View File

@@ -6,7 +6,6 @@ import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { ChartEmpty } from './ChartEmpty';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';

View File

@@ -44,6 +44,7 @@ export interface ButtonProps
asChild?: boolean;
loading?: boolean;
icon?: LucideIcon;
responsive?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -57,6 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
loading,
disabled,
icon,
responsive,
...props
},
ref
@@ -71,9 +73,19 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
>
{Icon && (
<Icon className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
<Icon
className={cn(
'h-4 w-4 mr-2',
responsive && 'mr-0 sm:mr-2',
loading && 'animate-spin'
)}
/>
)}
{responsive ? (
<span className="hidden sm:block">{children}</span>
) : (
children
)}
{children}
</Comp>
);
}

View File

@@ -0,0 +1,15 @@
import { useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash.throttle';
export function useThrottle(cb: () => void, delay: number) {
const options = { leading: true, trailing: false }; // add custom lodash options
const cbRef = useRef(cb);
// use mutable ref to make useCallback/throttle not depend on `cb` dep
useEffect(() => {
cbRef.current = cb;
});
return useCallback(
throttle(() => cbRef.current(), delay, options),
[delay]
);
}

View File

@@ -1,18 +0,0 @@
import * as cache from '@/server/cache';
import { db } from '@/server/db';
import { getUniqueEvents } from '@/server/services/event.service';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const projects = await db.project.findMany();
for (const project of projects) {
const events = await getUniqueEvents({ projectId: project.id });
cache.set(`events_${project.id}`, 1000 * 60 * 60 * 24, events);
}
res.status(200).json({ ok: true });
}

View File

@@ -1,41 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import { eventsQueue } from '@mixan/queue';
import type { BatchPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: BatchPayload[];
}
export const config = {
api: {
responseLimit: false,
},
};
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
await eventsQueue.add('batch', {
projectId,
payload: req.body,
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,40 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { EventPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: EventPayload[];
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
await db.event.createMany({
data: req.body.map((event) => ({
name: event.name,
properties: event.properties,
createdAt: event.time,
project_id: projectId,
profile_id: event.profileId,
})),
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,38 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfileIncrementPayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'PUT') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req, res);
const profileId = req.query.profileId as string;
await tickProfileProperty({
name: req.body.name,
tick: -Math.abs(req.body.value),
profileId,
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,38 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfileIncrementPayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'PUT') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req, res);
const profileId = req.query.profileId as string;
await tickProfileProperty({
name: req.body.name,
tick: req.body.value,
profileId,
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,54 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import { getProfile } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfilePayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'PUT' && req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req, res);
const profileId = req.query.profileId as string;
const profile = await getProfile(profileId);
const { body } = req;
await db.profile.update({
where: {
id: profileId,
},
data: {
external_id: body.id,
email: body.email,
first_name: body.first_name,
last_name: body.last_name,
avatar: body.avatar,
properties: {
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(body.properties ?? {}),
},
},
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,51 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import randomAnimalName from 'random-animal-name';
import type { CreateProfileResponse, ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfilePayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
// Providing an `ID` is deprecated, should be removed in the future
const profileId = 'id' in req.body ? req.body.id : undefined;
const { properties } = req.body ?? {};
const profile = await db.profile.create({
data: {
id: profileId,
external_id: null,
email: null,
first_name: randomAnimalName(),
last_name: null,
avatar: null,
properties: {
...(properties ?? {}),
},
project_id: projectId,
},
});
const response: CreateProfileResponse = { id: profile.id };
res.status(200).json(response);
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,32 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { eventsQueue } from '@mixan/queue';
import type { BatchPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: BatchPayload[];
}
export const config = {
api: {
responseLimit: false,
},
};
export default function handler(req: Request, res: NextApiResponse) {
eventsQueue.add('batch', {
payload: [
{
type: 'event',
payload: {
profileId: 'f8235c6a-c720-4f38-8f6c-b6b7d31e16db',
name: 'test',
properties: {},
time: new Date().toISOString(),
},
},
],
projectId: 'b725eadb-a1fe-4be8-bf0b-9d9bfa6aac12',
});
res.status(200).json({ status: 'ok' });
}

View File

@@ -5,7 +5,7 @@ import type {
IGetChartDataInput,
IInterval,
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import { alphabetIds, NOT_SET_VALUE } from '@/utils/constants';
import { round } from '@/utils/math';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
@@ -207,7 +207,7 @@ export async function getChartData(payload: IGetChartDataInput) {
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() || '(not set)';
const label = item.label?.trim() || NOT_SET_VALUE;
if (label) {
if (acc[label]) {
acc[label]?.push(item);

View File

@@ -29,19 +29,21 @@ interface Metrics {
};
}
interface FinalChart {
events: IChartInput['events'];
series: {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
export interface IChartSerie {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
}
export interface FinalChart {
events: IChartInput['events'];
series: IChartSerie[];
metrics: Metrics;
}

View File

@@ -1,9 +1,10 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { hashPassword } from '@/server/services/hash.service';
import { z } from 'zod';
import { hashPassword } from '@mixan/common';
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(

View File

@@ -29,6 +29,8 @@ export const eventRouter = createTRPCRouter({
sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`;
}
sb.orderBy.created_at = 'created_at DESC';
return (await chQuery<IDBEvent>(getSql())).map(transformEvent);
}),
});

View File

@@ -1,43 +0,0 @@
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
const keyLength = 32;
/**
* Has a password or a secret with a password hashing algorithm (scrypt)
* @param {string} password
* @returns {string} The salt+hash
*/
export async function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
// generate random 16 bytes long salt - recommended by NodeJS Docs
const salt = randomBytes(16).toString('hex');
scrypt(password, salt, keyLength, (err, derivedKey) => {
if (err) reject(err);
// derivedKey is of type Buffer
resolve(`${salt}.${derivedKey.toString('hex')}`);
});
});
}
/**
* Compare a plain text password with a salt+hash password
* @param {string} password The plain text password
* @param {string} hash The hash+salt to check against
* @returns {boolean}
*/
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, hashKey] = hash.split('.');
// we need to pass buffer values to timingSafeEqual
const hashKeyBuff = Buffer.from(hashKey!, 'hex');
scrypt(password, salt!, keyLength, (err, derivedKey) => {
if (err) {
reject(err);
}
// compare the new supplied password with the hashed password using timeSafeEqual
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
});
});
}

View File

@@ -1,3 +1,5 @@
export const NOT_SET_VALUE = '(not set)';
export const operators = {
is: 'Is',
isNot: 'Is not',