diff --git a/apps/sdk-api/src/utils/parseIp.ts b/apps/sdk-api/src/utils/parseIp.ts index ed945d53..1ccc22b2 100644 --- a/apps/sdk-api/src/utils/parseIp.ts +++ b/apps/sdk-api/src/utils/parseIp.ts @@ -1,4 +1,4 @@ -import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify'; +import type { FastifyRequest } from 'fastify'; interface RemoteIpLookupResponse { country: string | undefined; diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx index fa6a44c1..f29c4c3d 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx @@ -1,7 +1,28 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/app/_trpc/client'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; import { cn } from '@/utils/cn'; import type { VariantProps } from 'class-variance-authority'; import { cva } from 'class-variance-authority'; -import { ActivityIcon, BotIcon, MonitorPlayIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { ActivityIcon, BotIcon, DotIcon, MonitorPlayIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; + +import type { EventMeta } from '@mixan/db'; const variants = cva('flex items-center justify-center shrink-0', { variants: { @@ -17,30 +38,208 @@ const variants = cva('flex items-center justify-center shrink-0', { type EventIconProps = VariantProps & { name: string; + meta?: EventMeta; + projectId: string; className?: string; }; -const records = { - default: { Icon: BotIcon, text: 'text-chart-0', bg: 'bg-chart-0/10' }, +const records: Record< + string, + { + icon: string; + color: string; + } +> = { + default: { + icon: 'BotIcon', + color: 'slate', + }, screen_view: { - Icon: MonitorPlayIcon, - text: 'text-chart-3', - bg: 'bg-chart-3/10', + icon: 'MonitorPlayIcon', + color: 'blue', }, session_start: { - Icon: ActivityIcon, - text: 'text-chart-2', - bg: 'bg-chart-2/10', + icon: 'ActivityIcon', + color: 'teal', }, }; -export function EventIcon({ className, name, size }: EventIconProps) { - const { Icon, text, bg } = - name in records ? records[name as keyof typeof records] : records.default; +const icons: Record = { + BotIcon, + MonitorPlayIcon, + ActivityIcon, +}; + +const colors = [ + 'rose', + 'pink', + 'fuchsia', + 'purple', + 'violet', + 'indigo', + 'blue', + 'sky', + 'cyan', + 'teal', + 'emerald', + 'green', + 'lime', + 'yellow', + 'amber', + 'orange', + 'red', + 'stone', + 'neutral', + 'zinc', + 'grey', + 'slate', +]; + +export function EventIcon({ + className, + name, + size, + meta, + projectId, +}: EventIconProps) { + const router = useRouter(); + const [selectedIcon, setIcon] = useState( + meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? '' + ); + const [selectedColor, setColor] = useState( + meta?.color ?? records[name]?.color ?? records.default?.color ?? '' + ); + const [conversion, setConversion] = useState(!!meta?.conversion); + + useEffect(() => { + if (meta?.icon) { + setIcon(meta.icon); + } + }, [meta?.icon]); + useEffect(() => { + if (meta?.color) { + setColor(meta.color); + } + }, [meta?.color]); + useEffect(() => { + setConversion(meta?.conversion ?? false); + }, [meta?.conversion]); + + const SelectedIcon = icons[selectedIcon]!; + const Icon = + icons[meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? '']!; + const color = + meta?.color ?? records[name]?.color ?? records.default?.color ?? ''; + + const mutation = api.event.updateEventMeta.useMutation({ + onSuccess() { + document.querySelector('#close-sheet')?.click(); + toast('Event updated'); + router.refresh(); + }, + }); + const getBg = (color: string) => `bg-${color}-200`; + const getText = (color: string) => `text-${color}-700`; return ( -
- -
+ + + + + + + Edit "{name}" + +
+
+ + +
+
+ +
+ {Object.entries(icons).map(([name, Icon]) => ( + + ))} +
+
+
+ +
+ {colors.map((color) => ( + + ))} +
+
+
+ + + + +
+
); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx index b2cff72e..69dbba51 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx @@ -4,13 +4,16 @@ import type { RouterOutputs } from '@/app/_trpc/client'; import { ExpandableListItem } from '@/components/general/ExpandableListItem'; import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value'; import { useAppParams } from '@/hooks/useAppParams'; +import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; +import { cn } from '@/utils/cn'; import { getProfileName } from '@/utils/getters'; import { round } from '@/utils/math'; -import { useQueryState } from 'nuqs'; + +import type { IServiceCreateEventPayload } from '@mixan/db'; import { EventIcon } from './event-icon'; -type EventListItemProps = RouterOutputs['event']['list'][number]; +type EventListItemProps = IServiceCreateEventPayload; export function EventListItem({ profile, @@ -33,25 +36,11 @@ export function EventListItem({ country, continent, device, + projectId, + meta, }: EventListItemProps) { const params = useAppParams(); - - const [, setPath] = useQueryState('path'); - const [, setReferrer] = useQueryState('referrer'); - const [, setReferrerName] = useQueryState('referrerName'); - const [, setReferrerType] = useQueryState('referrerType'); - const [, setBrand] = useQueryState('brand'); - const [, setModel] = useQueryState('model'); - const [, setBrowser] = useQueryState('browser'); - const [, setBrowserVersion] = useQueryState('browserVersion'); - const [, setOs] = useQueryState('os'); - const [, setOsVersion] = useQueryState('osVersion'); - const [, setCity] = useQueryState('city'); - const [, setRegion] = useQueryState('region'); - const [, setCountry] = useQueryState('country'); - const [, setContinent] = useQueryState('continent'); - const [, setDevice] = useQueryState('device'); - + const eventQueryFilters = useEventQueryFilters({ shallow: false }); const keyValueList = [ { name: 'Duration', @@ -61,98 +50,98 @@ export function EventListItem({ name: 'Referrer', value: referrer, onClick() { - setReferrer(referrer ?? null); + eventQueryFilters.referrer.set(referrer ?? null); }, }, { name: 'Referrer name', value: referrerName, onClick() { - setReferrerName(referrerName ?? null); + eventQueryFilters.referrerName.set(referrerName ?? null); }, }, { name: 'Referrer type', value: referrerType, onClick() { - setReferrerType(referrerType ?? null); + eventQueryFilters.referrerType.set(referrerType ?? null); }, }, { name: 'Brand', value: brand, onClick() { - setBrand(brand ?? null); + eventQueryFilters.brand.set(brand ?? null); }, }, { name: 'Model', value: model, onClick() { - setModel(model ?? null); + eventQueryFilters.model.set(model ?? null); }, }, { name: 'Browser', value: browser, onClick() { - setBrowser(browser ?? null); + eventQueryFilters.browser.set(browser ?? null); }, }, { name: 'Browser version', value: browserVersion, onClick() { - setBrowserVersion(browserVersion ?? null); + eventQueryFilters.browserVersion.set(browserVersion ?? null); }, }, { name: 'OS', value: os, onClick() { - setOs(os ?? null); + eventQueryFilters.os.set(os ?? null); }, }, { name: 'OS cersion', value: osVersion, onClick() { - setOsVersion(osVersion ?? null); + eventQueryFilters.osVersion.set(osVersion ?? null); }, }, { name: 'City', value: city, onClick() { - setCity(city ?? null); + eventQueryFilters.city.set(city ?? null); }, }, { name: 'Region', value: region, onClick() { - setRegion(region ?? null); + eventQueryFilters.region.set(region ?? null); }, }, { name: 'Country', value: country, onClick() { - setCountry(country ?? null); + eventQueryFilters.country.set(country ?? null); }, }, { name: 'Continent', value: continent, onClick() { - setContinent(continent ?? null); + eventQueryFilters.continent.set(continent ?? null); }, }, { name: 'Device', value: device, onClick() { - setDevice(device ?? null); + eventQueryFilters.device.set(device ?? null); }, }, ].filter((item) => typeof item.value === 'string' && item.value); @@ -166,6 +155,7 @@ export function EventListItem({ return ( @@ -182,13 +172,13 @@ export function EventListItem({ name="Path" value={path} onClick={() => { - setPath(path); + eventQueryFilters.path.set(path); }} /> )} } - image={} + image={} >
diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx index b45fadae..824ad7c8 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx @@ -1,53 +1,69 @@ 'use client'; +import { Suspense } from 'react'; import { FullPageEmptyState } from '@/components/FullPageEmptyState'; import { Pagination } from '@/components/Pagination'; import { Button } from '@/components/ui/button'; import { useCursor } from '@/hooks/useCursor'; +import { useEventFilters } from '@/hooks/useEventQueryFilters'; import { GanttChartIcon } from 'lucide-react'; -import { last } from 'ramda'; -import { IServiceCreateEventPayload } from '@mixan/db'; +import type { IServiceCreateEventPayload } from '@mixan/db'; import { EventListItem } from './event-list-item'; interface EventListProps { data: IServiceCreateEventPayload[]; + count: number; } -export function EventList({ data }: EventListProps) { +export function EventList({ data, count }: EventListProps) { const { cursor, setCursor } = useCursor(); + const filters = useEventFilters(); + return ( - <> +
{data.length === 0 ? ( - {/* {filterEvents.length ? ( -

Could not find any events with your filter

+ {cursor !== 0 ? ( + <> +

Looks like you have reached the end of the list

+ + ) : ( -

We have not recieved any events yet

- )} */} -

We have not recieved any events yet

+ <> + {filters.length ? ( +

Could not find any events with your filter

+ ) : ( +

We have not recieved any events yet

+ )} + + )}
) : ( <> -
+ +
{data.map((item) => ( - + ))}
- + )}
- + ); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx index 49fff6d8..90759dd0 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx @@ -1,9 +1,10 @@ -import { Suspense } from 'react'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout'; +import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; +import { getEventFilters } from '@/hooks/useEventQueryFilters'; import { getExists } from '@/server/pageExists'; -import { getEventList, getEvents } from '@mixan/db'; +import { getEventList, getEventsCount } from '@mixan/db'; import { StickyBelowHeader } from '../layout-sticky-below-header'; import { EventList } from './event-list'; @@ -15,25 +16,114 @@ interface PageProps { }; searchParams: { cursor?: string; + path?: string; + device?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; + continent?: string; + country?: string; + region?: string; + city?: string; + browser?: string; + browserVersion?: string; + os?: string; + osVersion?: string; + brand?: string; + model?: string; }; } + +const nuqsOptions = { + shallow: false, +}; + +function parseQueryAsNumber(value: string | undefined) { + if (typeof value === 'string') { + return parseInt(value, 10); + } + return undefined; +} + export default async function Page({ params: { projectId, organizationId }, - searchParams: { cursor }, + searchParams, }: PageProps) { - await getExists(organizationId, projectId); - const events = await getEventList({ - cursor, - projectId, - take: 50, - }); + const [events, count] = await Promise.all([ + getEventList({ + cursor: parseQueryAsNumber(searchParams.cursor), + projectId, + take: 50, + filters: getEventFilters({ + path: searchParams.path ?? null, + device: searchParams.device ?? null, + referrer: searchParams.referrer ?? null, + referrerName: searchParams.referrerName ?? null, + referrerType: searchParams.referrerType ?? null, + utmSource: searchParams.utmSource ?? null, + utmMedium: searchParams.utmMedium ?? null, + utmCampaign: searchParams.utmCampaign ?? null, + utmContent: searchParams.utmContent ?? null, + utmTerm: searchParams.utmTerm ?? null, + continent: searchParams.continent ?? null, + country: searchParams.country ?? null, + region: searchParams.region ?? null, + city: searchParams.city ?? null, + browser: searchParams.browser ?? null, + browserVersion: searchParams.browserVersion ?? null, + os: searchParams.os ?? null, + osVersion: searchParams.osVersion ?? null, + brand: searchParams.brand ?? null, + model: searchParams.model ?? null, + }), + }), + getEventsCount({ + projectId, + filters: getEventFilters({ + path: searchParams.path ?? null, + device: searchParams.device ?? null, + referrer: searchParams.referrer ?? null, + referrerName: searchParams.referrerName ?? null, + referrerType: searchParams.referrerType ?? null, + utmSource: searchParams.utmSource ?? null, + utmMedium: searchParams.utmMedium ?? null, + utmCampaign: searchParams.utmCampaign ?? null, + utmContent: searchParams.utmContent ?? null, + utmTerm: searchParams.utmTerm ?? null, + continent: searchParams.continent ?? null, + country: searchParams.country ?? null, + region: searchParams.region ?? null, + city: searchParams.city ?? null, + browser: searchParams.browser ?? null, + browserVersion: searchParams.browserVersion ?? null, + os: searchParams.os ?? null, + osVersion: searchParams.osVersion ?? null, + brand: searchParams.brand ?? null, + model: searchParams.model ?? null, + }), + }), + getExists(organizationId, projectId), + ]); + console.log(events[0]); return ( - + + - + ); } diff --git a/apps/web/src/components/Pagination.tsx b/apps/web/src/components/Pagination.tsx index 2193e95b..e5ecab97 100644 --- a/apps/web/src/components/Pagination.tsx +++ b/apps/web/src/components/Pagination.tsx @@ -1,42 +1,54 @@ -import { useMemo, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useState } from 'react'; import { Button } from './ui/button'; -export function usePagination(take = 100) { - const [skip, setSkip] = useState(0); - return useMemo( - () => ({ - skip, - next: () => setSkip((p) => p + take), - prev: () => setSkip((p) => Math.max(p - take)), - take, - canPrev: skip > 0, - canNext: true, - page: skip / take + 1, - }), - [skip, setSkip, take] - ); +export function usePagination(take: number) { + const [page, setPage] = useState(0); + return { + take, + skip: page * take, + setPage, + page, + paginate: (data: T[]): T[] => + data.slice(page * take, (page + 1) * take), + }; } -export type PaginationProps = ReturnType; +export function Pagination({ + take, + count, + cursor, + setCursor, +}: { + take?: number; + count?: number; + cursor: number; + setCursor: Dispatch>; +}) { + const isNextDisabled = + count !== undefined && take !== undefined && cursor * take + take >= count; -export function Pagination(props: PaginationProps) { return (
-
Page: {props.page}
+
Page: {cursor + 1}
+ {typeof count === 'number' && ( +
Total rows: {count}
+ )} + diff --git a/apps/web/src/components/general/ExpandableListItem.tsx b/apps/web/src/components/general/ExpandableListItem.tsx index 1e33022c..9b585de9 100644 --- a/apps/web/src/components/general/ExpandableListItem.tsx +++ b/apps/web/src/components/general/ExpandableListItem.tsx @@ -11,6 +11,7 @@ interface ExpandableListItemProps { title: string; image?: React.ReactNode; initialOpen?: boolean; + className?: string; } export function ExpandableListItem({ title, @@ -18,14 +19,17 @@ export function ExpandableListItem({ image, initialOpen = false, children, + className, }: ExpandableListItemProps) { const [open, setOpen] = useState(initialOpen ?? false); return ( -
-
+
+
{image}
- {title} + {title} {!!content && (
{content} diff --git a/apps/web/src/components/overview/filters/overview-filters-buttons.tsx b/apps/web/src/components/overview/filters/overview-filters-buttons.tsx index 25c8dc01..a98e5f2f 100644 --- a/apps/web/src/components/overview/filters/overview-filters-buttons.tsx +++ b/apps/web/src/components/overview/filters/overview-filters-buttons.tsx @@ -1,22 +1,27 @@ 'use client'; import { Button } from '@/components/ui/button'; -import { - useEventFilters, - useEventQueryFilters, -} from '@/hooks/useEventQueryFilters'; +import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { cn } from '@/utils/cn'; import { X } from 'lucide-react'; +import { Options as NuqsOptions } from 'nuqs'; -export function OverviewFiltersButtons() { - const eventQueryFilters = useEventQueryFilters(); +interface OverviewFiltersButtonsProps { + className?: string; + nuqsOptions?: NuqsOptions; +} + +export function OverviewFiltersButtons({ + className, + nuqsOptions, +}: OverviewFiltersButtonsProps) { + const eventQueryFilters = useEventQueryFilters(nuqsOptions); const filters = Object.entries(eventQueryFilters).filter( ([, filter]) => filter.get !== null ); + if (filters.length === 0) return null; return ( -
0 && 'px-4 pb-4')} - > +
{filters.map(([key, filter]) => ( - + ); diff --git a/apps/web/src/components/report/chart/ReportTable.tsx b/apps/web/src/components/report/chart/ReportTable.tsx index 3656fe10..9bc4030b 100644 --- a/apps/web/src/components/report/chart/ReportTable.tsx +++ b/apps/web/src/components/report/chart/ReportTable.tsx @@ -1,5 +1,3 @@ -'use client'; - import * as React from 'react'; import type { IChartData } from '@/app/_trpc/client'; import { Pagination, usePagination } from '@/components/Pagination'; @@ -37,7 +35,7 @@ export function ReportTable({ visibleSeries, setVisibleSeries, }: ReportTableProps) { - const pagination = usePagination(50); + const { setPage, paginate, page } = usePagination(50); const number = useNumber(); const interval = useSelector((state) => state.report.interval); const formatDate = useFormatDateInterval(interval); @@ -63,46 +61,44 @@ export function ReportTable({ - {data.series - .slice(pagination.skip, pagination.skip + pagination.take) - .map((serie, index) => { - const checked = !!visibleSeries.find( - (item) => item.name === serie.name - ); + {paginate(data.series).map((serie, index) => { + const checked = !!visibleSeries.find( + (item) => item.name === serie.name + ); - return ( - - -
- - handleChange(serie.name, !!checked) - } - style={ - checked - ? { - background: getChartColor(index), - borderColor: getChartColor(index), - } - : undefined - } - checked={checked} - /> - - -
- {getLabel(serie.name)} -
-
- -

{getLabel(serie.name)}

-
-
-
-
-
- ); - })} + return ( + + +
+ + handleChange(serie.name, !!checked) + } + style={ + checked + ? { + background: getChartColor(index), + borderColor: getChartColor(index), + } + : undefined + } + checked={checked} + /> + + +
+ {getLabel(serie.name)} +
+
+ +

{getLabel(serie.name)}

+
+
+
+
+
+ ); + })}
@@ -122,44 +118,39 @@ export function ReportTable({ - {data.series - .slice(pagination.skip, pagination.skip + pagination.take) - .map((serie) => { - return ( - - -
- {number.format(serie.metrics.sum)} - -
-
- -
- {number.format(serie.metrics.average)} - -
-
+ {paginate(data.series).map((serie) => { + return ( + + +
+ {number.format(serie.metrics.sum)} + +
+
+ +
+ {number.format(serie.metrics.average)} + +
+
- {serie.data.map((item) => { - return ( - -
- {number.format(item.count)} - -
-
- ); - })} -
- ); - })} + {serie.data.map((item) => { + return ( + +
+ {number.format(item.count)} + +
+
+ ); + })} +
+ ); + })}
@@ -171,7 +162,7 @@ export function ReportTable({ Min: {number.format(data.metrics.min)} Max: {number.format(data.metrics.max)}
- +
); diff --git a/apps/web/src/components/ui/key-value.tsx b/apps/web/src/components/ui/key-value.tsx index 99d37b06..307b586b 100644 --- a/apps/web/src/components/ui/key-value.tsx +++ b/apps/web/src/components/ui/key-value.tsx @@ -37,13 +37,13 @@ export function KeyValueSubtle({ href, onClick, name, value }: KeyValueProps) { const Component = href ? (Link as any) : onClick ? 'button' : 'div'; return (
{name}
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index d2a3ff6e..4bd87934 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -3,10 +3,9 @@ import * as React from 'react'; import { cn } from '@/utils/cn'; import * as SheetPrimitive from '@radix-ui/react-dialog'; -import { ScrollArea } from '@radix-ui/react-scroll-area'; import { cva } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority'; -import { X } from 'lucide-react'; +import { XIcon } from 'lucide-react'; const Sheet = SheetPrimitive.Root; @@ -22,7 +21,7 @@ const SheetOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( (({ side = 'right', className, children, ...props }, ref) => ( - + -
- {children} -
+ {children} + - + Close
diff --git a/apps/web/src/hooks/useCursor.ts b/apps/web/src/hooks/useCursor.ts index d0037913..458c4710 100644 --- a/apps/web/src/hooks/useCursor.ts +++ b/apps/web/src/hooks/useCursor.ts @@ -1,9 +1,11 @@ -import { parseAsIsoDateTime, useQueryState } from 'nuqs'; +import { parseAsInteger, useQueryState } from 'nuqs'; export function useCursor() { const [cursor, setCursor] = useQueryState( 'cursor', - parseAsIsoDateTime.withOptions({ shallow: false }) + parseAsInteger + .withOptions({ shallow: false, history: 'push' }) + .withDefault(0) ); return { cursor, diff --git a/apps/web/src/hooks/useEventQueryFilters.ts b/apps/web/src/hooks/useEventQueryFilters.ts index 9a0e0add..f1ee78cd 100644 --- a/apps/web/src/hooks/useEventQueryFilters.ts +++ b/apps/web/src/hooks/useEventQueryFilters.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import type { IChartInput } from '@/types'; // prettier-ignore -import type { UseQueryStateReturn } from 'nuqs'; +import type { Options as NuqsOptions, UseQueryStateReturn } from 'nuqs'; import { parseAsString, useQueryState } from 'nuqs'; @@ -18,210 +18,309 @@ function useFix(hook: UseQueryStateReturn) { ); } -export function useEventQueryFilters() { +export function useEventQueryFilters(options: NuqsOptions = {}) { // Ignore prettier so that we have all one same line // prettier-ignore return { - path: useFix(useQueryState('path', parseAsString.withOptions(nuqsOptions))), - referrer: useFix(useQueryState('referrer', parseAsString.withOptions(nuqsOptions))), - referrerName: useFix(useQueryState('referrerName',parseAsString.withOptions(nuqsOptions))), - referrerType: useFix(useQueryState('referrerType',parseAsString.withOptions(nuqsOptions))), - utmSource: useFix(useQueryState('utmSource',parseAsString.withOptions(nuqsOptions))), - utmMedium: useFix(useQueryState('utmMedium',parseAsString.withOptions(nuqsOptions))), - utmCampaign: useFix(useQueryState('utmCampaign',parseAsString.withOptions(nuqsOptions))), - utmContent: useFix(useQueryState('utmContent',parseAsString.withOptions(nuqsOptions))), - utmTerm: useFix(useQueryState('utmTerm', parseAsString.withOptions(nuqsOptions))), - country: useFix(useQueryState('country', parseAsString.withOptions(nuqsOptions))), - region: useFix(useQueryState('region', parseAsString.withOptions(nuqsOptions))), - city: useFix(useQueryState('city', parseAsString.withOptions(nuqsOptions))), - device: useFix(useQueryState('device', parseAsString.withOptions(nuqsOptions))), - browser: useFix(useQueryState('browser', parseAsString.withOptions(nuqsOptions))), - browserVersion: useFix(useQueryState('browserVersion',parseAsString.withOptions(nuqsOptions))), - os: useFix(useQueryState('os', parseAsString.withOptions(nuqsOptions))), - osVersion: useFix(useQueryState('osVersion',parseAsString.withOptions(nuqsOptions))), + path: useFix(useQueryState('path', parseAsString.withOptions({...nuqsOptions, ...options}))), + referrer: useFix(useQueryState('referrer', parseAsString.withOptions({...nuqsOptions, ...options}))), + referrerName: useFix(useQueryState('referrerName',parseAsString.withOptions({...nuqsOptions, ...options}))), + referrerType: useFix(useQueryState('referrerType',parseAsString.withOptions({...nuqsOptions, ...options}))), + utmSource: useFix(useQueryState('utmSource',parseAsString.withOptions({...nuqsOptions, ...options}))), + utmMedium: useFix(useQueryState('utmMedium',parseAsString.withOptions({...nuqsOptions, ...options}))), + utmCampaign: useFix(useQueryState('utmCampaign',parseAsString.withOptions({...nuqsOptions, ...options}))), + utmContent: useFix(useQueryState('utmContent',parseAsString.withOptions({...nuqsOptions, ...options}))), + utmTerm: useFix(useQueryState('utmTerm', parseAsString.withOptions({...nuqsOptions, ...options}))), + continent: useFix(useQueryState('continent', parseAsString.withOptions({...nuqsOptions, ...options}))), + country: useFix(useQueryState('country', parseAsString.withOptions({...nuqsOptions, ...options}))), + region: useFix(useQueryState('region', parseAsString.withOptions({...nuqsOptions, ...options}))), + city: useFix(useQueryState('city', parseAsString.withOptions({...nuqsOptions, ...options}))), + device: useFix(useQueryState('device', parseAsString.withOptions({...nuqsOptions, ...options}))), + browser: useFix(useQueryState('browser', parseAsString.withOptions({...nuqsOptions, ...options}))), + browserVersion: useFix(useQueryState('browserVersion',parseAsString.withOptions({...nuqsOptions, ...options}))), + os: useFix(useQueryState('os', parseAsString.withOptions({...nuqsOptions, ...options}))), + osVersion: useFix(useQueryState('osVersion',parseAsString.withOptions({...nuqsOptions, ...options}))), + brand: useFix(useQueryState('brand',parseAsString.withOptions({...nuqsOptions, ...options}))), + model: useFix(useQueryState('model',parseAsString.withOptions({...nuqsOptions, ...options}))), } as const; } export function useEventFilters() { - const hej = useEventQueryFilters(); + const eventQueryFilters = useEventQueryFilters(); const filters = useMemo(() => { - const filters: IChartInput['events'][number]['filters'] = []; - - if (hej.path.get) { - filters.push({ - id: 'path', - operator: 'is', - name: 'path' as const, - value: [hej.path.get], - }); - } - - if (hej.device.get) { - filters.push({ - id: 'device', - operator: 'is', - name: 'device' as const, - value: [hej.device.get], - }); - } - - if (hej.referrer.get) { - filters.push({ - id: 'referrer', - operator: 'is', - name: 'referrer' as const, - value: [hej.referrer.get], - }); - } - console.log('hej.referrerName.get', hej.referrerName.get); - - if (hej.referrerName.get) { - filters.push({ - id: 'referrerName', - operator: 'is', - name: 'referrer_name' as const, - value: [hej.referrerName.get], - }); - } - - if (hej.referrerType.get) { - filters.push({ - id: 'referrerType', - operator: 'is', - name: 'referrer_type' as const, - value: [hej.referrerType.get], - }); - } - - if (hej.utmSource.get) { - filters.push({ - id: 'utmSource', - operator: 'is', - name: 'properties.query.utm_source' as const, - value: [hej.utmSource.get], - }); - } - - if (hej.utmMedium.get) { - filters.push({ - id: 'utmMedium', - operator: 'is', - name: 'properties.query.utm_medium' as const, - value: [hej.utmMedium.get], - }); - } - - if (hej.utmCampaign.get) { - filters.push({ - id: 'utmCampaign', - operator: 'is', - name: 'properties.query.utm_campaign' as const, - value: [hej.utmCampaign.get], - }); - } - - if (hej.utmContent.get) { - filters.push({ - id: 'utmContent', - operator: 'is', - name: 'properties.query.utm_content' as const, - value: [hej.utmContent.get], - }); - } - - if (hej.utmTerm.get) { - filters.push({ - id: 'utmTerm', - operator: 'is', - name: 'properties.query.utm_term' as const, - value: [hej.utmTerm.get], - }); - } - - if (hej.country.get) { - filters.push({ - id: 'country', - operator: 'is', - name: 'country' as const, - value: [hej.country.get], - }); - } - - if (hej.region.get) { - filters.push({ - id: 'region', - operator: 'is', - name: 'region' as const, - value: [hej.region.get], - }); - } - - if (hej.city.get) { - filters.push({ - id: 'city', - operator: 'is', - name: 'city' as const, - value: [hej.city.get], - }); - } - - if (hej.browser.get) { - filters.push({ - id: 'browser', - operator: 'is', - name: 'browser' as const, - value: [hej.browser.get], - }); - } - - if (hej.browserVersion.get) { - filters.push({ - id: 'browserVersion', - operator: 'is', - name: 'browser_version' as const, - value: [hej.browserVersion.get], - }); - } - - if (hej.os.get) { - filters.push({ - id: 'os', - operator: 'is', - name: 'os' as const, - value: [hej.os.get], - }); - } - - if (hej.osVersion.get) { - filters.push({ - id: 'osVersion', - operator: 'is', - name: 'os_version' as const, - value: [hej.osVersion.get], - }); - } - - return filters; + return getEventFilters({ + path: eventQueryFilters.path.get, + device: eventQueryFilters.device.get, + referrer: eventQueryFilters.referrer.get, + referrerName: eventQueryFilters.referrerName.get, + referrerType: eventQueryFilters.referrerType.get, + utmSource: eventQueryFilters.utmSource.get, + utmMedium: eventQueryFilters.utmMedium.get, + utmCampaign: eventQueryFilters.utmCampaign.get, + utmContent: eventQueryFilters.utmContent.get, + utmTerm: eventQueryFilters.utmTerm.get, + continent: eventQueryFilters.continent.get, + country: eventQueryFilters.country.get, + region: eventQueryFilters.region.get, + city: eventQueryFilters.city.get, + browser: eventQueryFilters.browser.get, + browserVersion: eventQueryFilters.browserVersion.get, + os: eventQueryFilters.os.get, + osVersion: eventQueryFilters.osVersion.get, + brand: eventQueryFilters.brand.get, + model: eventQueryFilters.model.get, + }); }, [ - hej.path, - hej.device, - hej.referrer, - hej.referrerName, - hej.referrerType, - hej.utmSource, - hej.utmMedium, - hej.utmCampaign, - hej.utmContent, - hej.utmTerm, - hej.country, - hej.region, - hej.city, - hej.browser, - hej.browserVersion, - hej.os, - hej.osVersion, + eventQueryFilters.path.get, + eventQueryFilters.device.get, + eventQueryFilters.referrer.get, + eventQueryFilters.referrerName.get, + eventQueryFilters.referrerType.get, + eventQueryFilters.utmSource.get, + eventQueryFilters.utmMedium.get, + eventQueryFilters.utmCampaign.get, + eventQueryFilters.utmContent.get, + eventQueryFilters.utmTerm.get, + eventQueryFilters.continent.get, + eventQueryFilters.country.get, + eventQueryFilters.region.get, + eventQueryFilters.city.get, + eventQueryFilters.browser.get, + eventQueryFilters.browserVersion.get, + eventQueryFilters.os.get, + eventQueryFilters.osVersion.get, + eventQueryFilters.model.get, + eventQueryFilters.brand.get, ]); return filters; } + +export function getEventFilters({ + path, + device, + referrer, + referrerName, + referrerType, + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + continent, + country, + region, + city, + browser, + browserVersion, + os, + osVersion, + brand, + model, +}: { + path: string | null; + device: string | null; + referrer: string | null; + referrerName: string | null; + referrerType: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + utmTerm: string | null; + continent: string | null; + country: string | null; + region: string | null; + city: string | null; + browser: string | null; + browserVersion: string | null; + os: string | null; + osVersion: string | null; + brand: string | null; + model: string | null; +}) { + const filters: IChartInput['events'][number]['filters'] = []; + + if (path) { + filters.push({ + id: 'path', + operator: 'is', + name: 'path' as const, + value: [path], + }); + } + + if (device) { + filters.push({ + id: 'device', + operator: 'is', + name: 'device' as const, + value: [device], + }); + } + + if (referrer) { + filters.push({ + id: 'referrer', + operator: 'is', + name: 'referrer' as const, + value: [referrer], + }); + } + + if (referrerName) { + filters.push({ + id: 'referrerName', + operator: 'is', + name: 'referrer_name' as const, + value: [referrerName], + }); + } + + if (referrerType) { + filters.push({ + id: 'referrerType', + operator: 'is', + name: 'referrer_type' as const, + value: [referrerType], + }); + } + + if (utmSource) { + filters.push({ + id: 'utmSource', + operator: 'is', + name: 'properties.query.utm_source' as const, + value: [utmSource], + }); + } + + if (utmMedium) { + filters.push({ + id: 'utmMedium', + operator: 'is', + name: 'properties.query.utm_medium' as const, + value: [utmMedium], + }); + } + + if (utmCampaign) { + filters.push({ + id: 'utmCampaign', + operator: 'is', + name: 'properties.query.utm_campaign' as const, + value: [utmCampaign], + }); + } + + if (utmContent) { + filters.push({ + id: 'utmContent', + operator: 'is', + name: 'properties.query.utm_content' as const, + value: [utmContent], + }); + } + + if (utmTerm) { + filters.push({ + id: 'utmTerm', + operator: 'is', + name: 'properties.query.utm_term' as const, + value: [utmTerm], + }); + } + + if (continent) { + filters.push({ + id: 'continent', + operator: 'is', + name: 'continent' as const, + value: [continent], + }); + } + + if (country) { + filters.push({ + id: 'country', + operator: 'is', + name: 'country' as const, + value: [country], + }); + } + + if (region) { + filters.push({ + id: 'region', + operator: 'is', + name: 'region' as const, + value: [region], + }); + } + + if (city) { + filters.push({ + id: 'city', + operator: 'is', + name: 'city' as const, + value: [city], + }); + } + + if (browser) { + filters.push({ + id: 'browser', + operator: 'is', + name: 'browser' as const, + value: [browser], + }); + } + + if (browserVersion) { + filters.push({ + id: 'browserVersion', + operator: 'is', + name: 'browser_version' as const, + value: [browserVersion], + }); + } + + if (os) { + filters.push({ + id: 'os', + operator: 'is', + name: 'os' as const, + value: [os], + }); + } + + if (osVersion) { + filters.push({ + id: 'osVersion', + operator: 'is', + name: 'os_version' as const, + value: [osVersion], + }); + } + + if (brand) { + filters.push({ + id: 'brand', + operator: 'is', + name: 'brand' as const, + value: [brand], + }); + } + + if (model) { + filters.push({ + id: 'model', + operator: 'is', + name: 'model' as const, + value: [model], + }); + } + + return filters; +} diff --git a/apps/web/src/server/api/routers/event.ts b/apps/web/src/server/api/routers/event.ts index 4318bf70..a93c0d16 100644 --- a/apps/web/src/server/api/routers/event.ts +++ b/apps/web/src/server/api/routers/event.ts @@ -1,38 +1,29 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; -import { transformEvent } from '@/server/services/event.service'; import { z } from 'zod'; -import type { IDBEvent } from '@mixan/db'; -import { chQuery, createSqlBuilder, getEvents } from '@mixan/db'; +import { db } from '@mixan/db'; export const eventRouter = createTRPCRouter({ - list: protectedProcedure + updateEventMeta: protectedProcedure .input( z.object({ projectId: z.string(), - take: z.number().default(100), - skip: z.number().default(0), - profileId: z.string().optional(), - events: z.array(z.string()).optional(), + name: z.string(), + icon: z.string().optional(), + color: z.string().optional(), + conversion: z.boolean().optional(), }) ) - .query(async ({ input: { take, skip, projectId, profileId, events } }) => { - const { sb, getSql } = createSqlBuilder(); - - sb.limit = take; - sb.offset = skip; - sb.where.projectId = `project_id = '${projectId}'`; - if (profileId) { - sb.where.profileId = `profile_id = '${profileId}'`; - } - if (events?.length) { - sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`; - } - - sb.orderBy.created_at = 'created_at DESC'; - - const res = await getEvents(getSql(), { profile: true }); - - return res; + .mutation(({ input: { projectId, name, icon, color, conversion } }) => { + return db.eventMeta.upsert({ + where: { + name_project_id: { + name, + project_id: projectId, + }, + }, + create: { project_id: projectId, name, icon, color, conversion }, + update: { icon, color, conversion }, + }); }), }); diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 6895c32f..57a39e7a 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -20,6 +20,50 @@ const config = { ...colors.flatMap((color) => ['text', 'bg'].map((prefix) => `${prefix}-chart-${color}`) ), + 'bg-rose-200', + 'text-rose-700', + 'bg-pink-200', + 'text-pink-700', + 'bg-fuchsia-200', + 'text-fuchsia-700', + 'bg-purple-200', + 'text-purple-700', + 'bg-violet-200', + 'text-violet-700', + 'bg-indigo-200', + 'text-indigo-700', + 'bg-blue-200', + 'text-blue-700', + 'bg-sky-200', + 'text-sky-700', + 'bg-cyan-200', + 'text-cyan-700', + 'bg-teal-200', + 'text-teal-700', + 'bg-emerald-200', + 'text-emerald-700', + 'bg-green-200', + 'text-green-700', + 'bg-lime-200', + 'text-lime-700', + 'bg-yellow-200', + 'text-yellow-700', + 'bg-amber-200', + 'text-amber-700', + 'bg-orange-200', + 'text-orange-700', + 'bg-red-200', + 'text-red-700', + 'bg-stone-200', + 'text-stone-700', + 'bg-neutral-200', + 'text-neutral-700', + 'bg-zinc-200', + 'text-zinc-700', + 'bg-grey-200', + 'text-grey-700', + 'bg-slate-200', + 'text-slate-700', ], content: [ './pages/**/*.{ts,tsx}', diff --git a/packages/db/clickhouse_tables.sql b/packages/db/clickhouse_tables.sql index dc765520..2751a8e7 100644 --- a/packages/db/clickhouse_tables.sql +++ b/packages/db/clickhouse_tables.sql @@ -1,10 +1,5 @@ -ALTER TABLE - events -ADD - COLUMN id UUID; - CREATE TABLE openpanel.events ( - `id` UUID, + `id` UUID DEFAULT generateUUIDv4(), `name` String, `profile_id` String, `project_id` String, @@ -18,6 +13,7 @@ CREATE TABLE openpanel.events ( `country` String, `city` String, `region` String, + `continent` String, `os` String, `os_version` String, `browser` String, @@ -44,4 +40,14 @@ CREATE TABLE test.profiles ( `created_at` DateTime ) ENGINE = ReplacingMergeTree ORDER BY - (id) SETTINGS index_granularity = 8192; \ No newline at end of file + (id) SETTINGS index_granularity = 8192; + +ALTER TABLE + events +ADD + COLUMN continent String +AFTER + region; + +ALTER TABLE + events DROP COLUMN id; \ No newline at end of file diff --git a/packages/db/index.ts b/packages/db/index.ts index d5c29737..5d450b10 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -5,3 +5,4 @@ export * from './src/sql-builder'; export * from './src/services/salt'; export * from './src/services/event.service'; export * from './src/services/share.service'; +export * from './src/services/chart.service'; diff --git a/packages/db/prisma/migrations/20240216202332_add_project_id_to_event_meta/migration.sql b/packages/db/prisma/migrations/20240216202332_add_project_id_to_event_meta/migration.sql new file mode 100644 index 00000000..6424bc0a --- /dev/null +++ b/packages/db/prisma/migrations/20240216202332_add_project_id_to_event_meta/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `project_id` to the `event_meta` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "event_meta" ADD COLUMN "project_id" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "event_meta" ADD CONSTRAINT "event_meta_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240216202514_fix_event_meta/migration.sql b/packages/db/prisma/migrations/20240216202514_fix_event_meta/migration.sql new file mode 100644 index 00000000..dd5f8272 --- /dev/null +++ b/packages/db/prisma/migrations/20240216202514_fix_event_meta/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "event_meta" ADD COLUMN "color" TEXT, +ADD COLUMN "icon" TEXT, +ALTER COLUMN "conversion" DROP NOT NULL; diff --git a/packages/db/prisma/migrations/20240216202657_add_unique_event_meta/migration.sql b/packages/db/prisma/migrations/20240216202657_add_unique_event_meta/migration.sql new file mode 100644 index 00000000..35aad7c9 --- /dev/null +++ b/packages/db/prisma/migrations/20240216202657_add_unique_event_meta/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name,project_id]` on the table `event_meta` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "event_meta_name_project_id_key" ON "event_meta"("name", "project_id"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ff6ec72d..261b0356 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -24,6 +24,7 @@ model Project { reports Report[] dashboards Dashboard[] share ShareOverview? + EventMeta EventMeta[] @@map("projects") } @@ -169,9 +170,15 @@ model ShareOverview { model EventMeta { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String - conversion Boolean - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + conversion Boolean? + color String? + icon String? + project_id String + project Project @relation(fields: [project_id], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([name, project_id]) @@map("event_meta") } diff --git a/packages/db/src/clickhouse-client.ts b/packages/db/src/clickhouse-client.ts index 65946db0..a5069ff5 100644 --- a/packages/db/src/clickhouse-client.ts +++ b/packages/db/src/clickhouse-client.ts @@ -14,7 +14,7 @@ interface ClickhouseJsonResponse { meta: { name: string; type: string }[]; } -export async function chQueryAll>( +export async function chQueryWithMeta>( query: string ): Promise> { const res = await ch.query({ @@ -42,7 +42,7 @@ export async function chQueryAll>( export async function chQuery>( query: string ): Promise { - return (await chQueryAll(query)).data; + return (await chQueryWithMeta(query)).data; } export function formatClickhouseDate(_date: Date | string) { diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts new file mode 100644 index 00000000..ff0654e9 --- /dev/null +++ b/packages/db/src/services/chart.service.ts @@ -0,0 +1,178 @@ +import { formatClickhouseDate } from '../clickhouse-client'; +import type { SqlBuilderObject } from '../sql-builder'; +import { createSqlBuilder } from '../sql-builder'; + +function log(sql: string) { + const logs = ['--- START', sql, '--- END']; + console.log(logs.join('\n')); + return sql; +} + +type IGetChartDataInput = any; + +export function getChartSql({ + event, + breakdowns, + interval, + startDate, + endDate, + projectId, +}: IGetChartDataInput) { + const { sb, join, getWhere, getFrom, getSelect, getOrderBy, getGroupBy } = + createSqlBuilder(); + + sb.where.projectId = `project_id = '${projectId}'`; + if (event.name !== '*') { + sb.select.label = `'${event.name}' as label`; + sb.where.eventName = `name = '${event.name}'`; + } + + getEventFiltersWhereClause(sb, event.filters); + + sb.select.count = `count(*) as count`; + switch (interval) { + case 'minute': { + sb.select.date = `toStartOfMinute(created_at) as date`; + break; + } + case 'hour': { + sb.select.date = `toStartOfHour(created_at) as date`; + break; + } + case 'day': { + sb.select.date = `toStartOfDay(created_at) as date`; + break; + } + case 'month': { + sb.select.date = `toStartOfMonth(created_at) as date`; + break; + } + } + sb.groupBy.date = 'date'; + sb.orderBy.date = 'date ASC'; + + if (startDate) { + sb.where.startDate = `created_at >= '${formatClickhouseDate(startDate)}'`; + } + + if (endDate) { + sb.where.endDate = `created_at <= '${formatClickhouseDate(endDate)}'`; + } + + const breakdown = breakdowns[0]!; + if (breakdown) { + const value = breakdown.name.startsWith('properties.') + ? `mapValues(mapExtractKeyLike(properties, '${breakdown.name + .replace(/^properties\./, '') + .replace('.*.', '.%.')}'))` + : breakdown.name; + sb.select.label = breakdown.name.startsWith('properties.') + ? `arrayElement(${value}, 1) as label` + : `${breakdown.name} as label`; + sb.groupBy.label = `label`; + } + + if (event.segment === 'user') { + sb.select.count = `countDistinct(profile_id) as count`; + } + + if (event.segment === 'user_average') { + sb.select.count = `COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`; + } + + if (event.segment === 'property_sum' && event.property) { + sb.select.count = `sum(${event.property}) as count`; + } + + if (event.segment === 'property_average' && event.property) { + sb.select.count = `avg(${event.property}) as count`; + } + + if (event.segment === 'one_event_per_user') { + sb.from = `( + SELECT DISTINCT ON (profile_id) * from events WHERE ${join( + sb.where, + ' AND ' + )} + ORDER BY profile_id, created_at DESC + ) as subQuery`; + + return log(`${getSelect()} ${getFrom()} ${getGroupBy()} ${getOrderBy()}`); + } + + return log( + `${getSelect()} ${getFrom()} ${getWhere()} ${getGroupBy()} ${getOrderBy()}` + ); +} + +export function getEventFiltersWhereClause( + sb: SqlBuilderObject, + filters: any[] +) { + filters.forEach((filter, index) => { + const id = `f${index}`; + const { name, value, operator } = filter; + + if (name.startsWith('properties.')) { + const whereFrom = `mapValues(mapExtractKeyLike(properties, '${name + .replace(/^properties\./, '') + .replace('.*.', '.%.')}'))`; + + switch (operator) { + case 'is': { + sb.where[id] = `arrayExists(x -> ${value + .map((val) => `x = '${String(val).trim()}'`) + .join(' OR ')}, ${whereFrom})`; + break; + } + case 'isNot': { + sb.where[id] = `arrayExists(x -> ${value + .map((val) => `x != '${String(val).trim()}'`) + .join(' OR ')}, ${whereFrom})`; + break; + } + case 'contains': { + sb.where[id] = `arrayExists(x -> ${value + .map((val) => `x LIKE '%${String(val).trim()}%'`) + .join(' OR ')}, ${whereFrom})`; + break; + } + case 'doesNotContain': { + sb.where[id] = `arrayExists(x -> ${value + .map((val) => `x NOT LIKE '%${String(val).trim()}%'`) + .join(' OR ')}, ${whereFrom})`; + break; + } + } + } else { + switch (operator) { + case 'is': { + sb.where[id] = `${name} IN (${value + .map((val) => `'${String(val).trim()}'`) + .join(', ')})`; + break; + } + case 'isNot': { + sb.where[id] = `${name} NOT IN (${value + .map((val) => `'${String(val).trim()}'`) + .join(', ')})`; + break; + } + case 'contains': { + sb.where[id] = value + .map((val) => `${name} LIKE '%${String(val).trim()}%'`) + .join(' OR '); + break; + } + case 'doesNotContain': { + sb.where[id] = value + .map((val) => `${name} NOT LIKE '%${String(val).trim()}%'`) + .join(' OR '); + break; + } + } + } + }); + + return sb; +} diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 90c7b256..9f4de93e 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -1,4 +1,4 @@ -import { omit } from 'ramda'; +import { omit, uniq } from 'ramda'; import { v4 as uuid } from 'uuid'; import { randomSplitName, toDots } from '@mixan/common'; @@ -10,10 +10,11 @@ import { convertClickhouseDateToJs, formatClickhouseDate, } from '../clickhouse-client'; -import type { Prisma } from '../prisma-client'; +import type { EventMeta, Prisma } from '../prisma-client'; import { db } from '../prisma-client'; import type { IDBProfile } from '../prisma-types'; import { createSqlBuilder } from '../sql-builder'; +import { getEventFiltersWhereClause } from './chart.service'; export interface IClickhouseEvent { id: string; @@ -37,7 +38,10 @@ export interface IClickhouseEvent { device: string; brand: string; model: string; + + // They do not exist here. Just make ts happy for now profile?: IDBProfile; + meta?: EventMeta; } export function transformEvent( @@ -66,6 +70,7 @@ export function transformEvent( referrerName: event.referrer_name, referrerType: event.referrer_type, profile: event.profile, + meta: event.meta, }; } @@ -95,11 +100,13 @@ export interface IServiceCreateEventPayload { referrer: string | undefined; referrerName: string | undefined; referrerType: string | undefined; - profile?: IDBProfile; + profile: IDBProfile | undefined; + meta: EventMeta | undefined; } interface GetEventsOptions { profile?: boolean | Prisma.ProfileSelect; + meta?: boolean | Prisma.EventMetaSelect; } export async function getLiveVisitors(projectId: string) { @@ -129,6 +136,22 @@ export async function getEvents( | undefined; } } + + if (options.meta) { + const names = uniq(events.map((e) => e.name)); + const metas = await db.eventMeta.findMany({ + where: { + name: { + in: names, + }, + project_id: events[0]?.project_id, + }, + select: options.meta === true ? undefined : options.meta, + }); + for (const event of events) { + event.meta = metas.find((m) => m.name === event.name); + } + } return events.map(transformEvent); } @@ -227,7 +250,8 @@ interface GetEventListOptions { projectId: string; profileId?: string; take: number; - cursor?: string; + cursor?: number; + filters: any[]; } export async function getEventList({ @@ -235,22 +259,44 @@ export async function getEventList({ take, projectId, profileId, + filters, }: GetEventListOptions) { const { sb, getSql } = createSqlBuilder(); sb.limit = take; + sb.offset = (cursor ?? 0) * take; sb.where.projectId = `project_id = '${projectId}'`; if (profileId) { sb.where.profileId = `profile_id = '${profileId}'`; } - if (cursor) { - sb.where.cursor = `created_at <= '${formatClickhouseDate(cursor)}'`; - } + getEventFiltersWhereClause(sb, filters); + + // if (cursor) { + // sb.where.cursor = `created_at <= '${formatClickhouseDate(cursor)}'`; + // } sb.orderBy.created_at = 'created_at DESC'; - const res = await getEvents(getSql(), { profile: true }); - - return res; + return getEvents(getSql(), { profile: true, meta: true }); +} + +export async function getEventsCount({ + projectId, + profileId, + filters, +}: Omit) { + const { sb, getSql } = createSqlBuilder(); + sb.where.projectId = `project_id = '${projectId}'`; + if (profileId) { + sb.where.profileId = `profile_id = '${profileId}'`; + } + + getEventFiltersWhereClause(sb, filters); + + const res = await chQuery<{ count: number }>( + getSql().replace('*', 'count(*) as count') + ); + + return res[0]?.count ?? 0; } diff --git a/packages/db/src/sql-builder.ts b/packages/db/src/sql-builder.ts index ed79ff8b..e9611b09 100644 --- a/packages/db/src/sql-builder.ts +++ b/packages/db/src/sql-builder.ts @@ -1,16 +1,18 @@ +export interface SqlBuilderObject { + where: Record; + select: Record; + groupBy: Record; + orderBy: Record; + from: string; + limit: number | undefined; + offset: number | undefined; +} + export function createSqlBuilder() { const join = (obj: Record | string[], joiner: string) => Object.values(obj).filter(Boolean).join(joiner); - const sb: { - where: Record; - select: Record; - groupBy: Record; - orderBy: Record; - from: string; - limit: number | undefined; - offset: number | undefined; - } = { + const sb: SqlBuilderObject = { where: {}, from: 'openpanel.events', select: {},