15 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
bcddc6f284 wip 2026-01-25 14:36:44 +01:00
Carl-Gerhard Lindesvärd
286f8e160b feat: improve webhook integration (customized body and headers) 2026-01-23 15:01:34 +01:00
Carl-Gerhard Lindesvärd
f8f470adf9 fix: notifications on session_start 2026-01-23 10:20:09 +01:00
Carl-Gerhard Lindesvärd
e7c2834ea0 fix: profile metric overflow issues 2026-01-22 22:18:01 +01:00
Carl-Gerhard Lindesvärd
753d6dce4c fix: encode profile ids #280 2026-01-22 22:08:41 +01:00
Carl-Gerhard Lindesvärd
9e5b482447 fix: report intervals 2026-01-22 21:49:13 +01:00
Carl-Gerhard Lindesvärd
32ea28b2f6 feat: improve activity chart on profile 2026-01-22 20:54:08 +01:00
Carl-Gerhard Lindesvärd
b39d076b32 feat: add sortable overview widgets 2026-01-22 20:53:05 +01:00
Carl-Gerhard Lindesvärd
ec5937e55c docs: add widget 2026-01-22 14:43:57 +01:00
Carl-Gerhard Lindesvärd
f83fe7a0fc fix: react email 2026-01-22 12:32:14 +01:00
Carl-Gerhard Lindesvärd
6c56efdf37 fix: dockerfile worker 2026-01-22 11:21:49 +01:00
Carl-Gerhard Lindesvärd
e5be28a49d fix: link 2026-01-22 10:48:11 +01:00
Carl-Gerhard Lindesvärd
e645c094b2 feature: onboarding emails
* wip

* wip

* wip

* fix coderabbit comments

* remove template
2026-01-22 10:38:05 +01:00
Carl-Gerhard Lindesvärd
67301d928c feat: add product hunt badge widget 2026-01-22 10:11:44 +01:00
Carl-Gerhard Lindesvärd
deb3c3d20c chore: add assign product to org script 2026-01-21 21:56:20 +01:00
63 changed files with 2851 additions and 274 deletions

View File

@@ -41,6 +41,7 @@ COPY packages/payments/package.json packages/payments/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/js-runtime/package.json packages/js-runtime/
COPY patches ./patches
# BUILD
@@ -108,6 +109,7 @@ COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen

View File

@@ -7,7 +7,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { getRedisCache, getRedisQueue } from '@openpanel/redis';
import {
type IDecrementPayload,
@@ -419,7 +419,7 @@ export async function fetchDeviceId(
});
try {
const multi = getRedisCache().multi();
const multi = getRedisQueue().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec();

View File

@@ -189,28 +189,48 @@ export default function OpenSourcePage() {
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
icon={GlobeIcon}
>
<p className="text-sm text-muted-foreground mt-2">
Display real-time visitor counts, page views, or other
metrics on your project's website.
</p>
<a
href="https://openpanel.dev"
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: '8px',
width: '250px',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</FeatureCard>
<p className="text-muted-foreground">
That's it. No complicated requirements, no hidden fees, no
catch. We just want to help open source projects succeed.
</p>
</div>
<div className="text-center text-xs text-muted-foreground">
<iframe
title="Realtime Widget"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
width="300"
height="400"
className="rounded-xl border mb-2"
/>
Analytics from{' '}
<a className="underline" href="https://openpanel.dev">
OpenPanel.dev
</a>
<div>
<div className="text-center text-xs text-muted-foreground">
<iframe
title="Realtime Widget"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
width="300"
height="400"
className="rounded-xl border mb-2"
/>
Analytics from{' '}
<a className="underline" href="https://openpanel.dev">
OpenPanel.dev
</a>
</div>
</div>
</div>
</Section>

View File

@@ -71,6 +71,7 @@ export function FeatureCard({
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</FeatureCardContainer>
);
}

View File

@@ -79,10 +79,28 @@ export async function Footer() {
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
<div className="container col md:row justify-between gap-8">
<div>
<Link href="/" className="row items-center font-medium -ml-3">
<Logo className="h-6" />
{baseOptions().nav?.title}
</Link>
<a
href="https://openpanel.dev"
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: '8px',
width: '100%',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</div>
<Social />
</div>

View File

@@ -84,9 +84,16 @@
"@types/d3": "^7.4.3",
"ai": "^4.2.10",
"bind-event-listener": "^3.0.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-javascript": "^6.2.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.35.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"codemirror": "^6.0.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"debounce": "^2.2.0",

View File

@@ -106,7 +106,7 @@ export function useColumns() {
if (profile) {
return (
<ProjectLink
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
@@ -117,7 +117,7 @@ export function useColumns() {
if (profileId && profileId !== deviceId) {
return (
<ProjectLink
href={`/profiles/${profileId}`}
href={`/profiles/${encodeURIComponent(profileId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
Unknown
@@ -128,7 +128,7 @@ export function useColumns() {
if (deviceId) {
return (
<ProjectLink
href={`/profiles/${deviceId}`}
href={`/profiles/${encodeURIComponent(deviceId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
Anonymous

View File

@@ -1,18 +1,53 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { JsonEditor } from '@/components/json-editor';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { path, mergeDeepRight } from 'ramda';
import { useEffect } from 'react';
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
const DEFAULT_TRANSFORMER = `(payload) => {
return payload;
}`;
// Convert Record<string, string> to array format for form
function headersToArray(
headers: Record<string, string> | undefined,
): { key: string; value: string }[] {
if (!headers || Object.keys(headers).length === 0) {
return [];
}
return Object.entries(headers).map(([key, value]) => ({ key, value }));
}
// Convert array format back to Record<string, string> for API
function headersToRecord(
headers: { key: string; value: string }[],
): Record<string, string> {
return headers.reduce(
(acc, { key, value }) => {
if (key.trim()) {
acc[key.trim()] = value;
}
return acc;
},
{} as Record<string, string>,
);
}
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
@@ -21,6 +56,13 @@ export function WebhookIntegrationForm({
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
// Convert headers from Record to array format for form UI
const defaultHeaders =
defaultValues?.config && 'headers' in defaultValues.config
? headersToArray(defaultValues.config.headers)
: [];
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
@@ -30,18 +72,68 @@ export function WebhookIntegrationForm({
type: 'webhook' as const,
url: '',
headers: {},
mode: 'message' as const,
javascriptTemplate: undefined,
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
// Use a separate form for headers array to work with useFieldArray
const headersForm = useForm<{ headers: { key: string; value: string }[] }>({
defaultValues: {
headers: defaultHeaders,
},
});
const headersArray = useFieldArray({
control: headersForm.control,
name: 'headers',
});
// Watch headers array and sync to main form
const watchedHeaders = useWatch({
control: headersForm.control,
name: 'headers',
});
// Sync headers array changes back to main form
useEffect(() => {
if (watchedHeaders) {
const validHeaders = watchedHeaders.filter(
(h): h is { key: string; value: string } =>
h !== undefined &&
typeof h.key === 'string' &&
typeof h.value === 'string',
);
form.setValue('config.headers', headersToRecord(validHeaders), {
shouldValidate: false,
});
}
}, [watchedHeaders, form]);
const mode = form.watch('config.mode');
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
onError(error) {
// Handle validation errors from tRPC
if (error.data?.code === 'BAD_REQUEST') {
const errorMessage = error.message || 'Invalid JavaScript template';
toast.error(errorMessage);
// Set form error if it's a JavaScript template error
if (errorMessage.includes('JavaScript template')) {
form.setError('config.javascriptTemplate', {
type: 'manual',
message: errorMessage,
});
}
} else {
toast.error('Failed to create integration');
}
},
}),
);
@@ -70,7 +162,176 @@ export function WebhookIntegrationForm({
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
<WithLabel
label="Headers"
info="Add custom HTTP headers to include with webhook requests"
>
<div className="col gap-2">
{headersArray.fields.map((field, index) => (
<div key={field.id} className="row gap-2">
<Input
placeholder="Header Name"
{...headersForm.register(`headers.${index}.key`)}
className="flex-1"
/>
<Input
placeholder="Header Value"
{...headersForm.register(`headers.${index}.value`)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => headersArray.remove(index)}
className="text-destructive"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => headersArray.append({ key: '', value: '' })}
className="self-start"
icon={PlusIcon}
>
Add Header
</Button>
</div>
</WithLabel>
<Controller
control={form.control}
name="config.mode"
render={({ field }) => (
<WithLabel
label="Payload Format"
info="Choose how to format the webhook payload"
>
<Combobox
{...field}
className="w-full"
placeholder="Select format"
items={[
{
label: 'Message',
value: 'message' as const,
},
{
label: 'JavaScript',
value: 'javascript' as const,
},
]}
value={field.value ?? 'message'}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
{mode === 'javascript' && (
<Controller
control={form.control}
name="config.javascriptTemplate"
render={({ field }) => (
<WithLabel
label="JavaScript Transform"
info={
<div className="prose dark:prose-invert max-w-none">
<p>
Write a JavaScript function that transforms the event
payload. The function receives <code>payload</code> as a
parameter and should return an object.
</p>
<p className="text-sm font-semibold mt-2">
Available in payload:
</p>
<ul className="text-sm">
<li>
<code>payload.name</code> - Event name
</li>
<li>
<code>payload.profileId</code> - User profile ID
</li>
<li>
<code>payload.properties</code> - Full properties object
</li>
<li>
<code>payload.properties.your.property</code> - Nested
property value
</li>
<li>
<code>payload.profile.firstName</code> - Profile property
</li>
<li>
<div className="flex gap-x-2 flex-wrap mt-1">
<code>country</code>
<code>city</code>
<code>device</code>
<code>os</code>
<code>browser</code>
<code>path</code>
<code>createdAt</code>
and more...
</div>
</li>
</ul>
<p className="text-sm font-semibold mt-2">
Available helpers:
</p>
<ul className="text-sm">
<li>
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
<code>Array</code>, <code>String</code>,{' '}
<code>Object</code>
</li>
</ul>
<p className="text-sm mt-2">
<strong>Example:</strong>
</p>
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
{`(payload) => ({
event: payload.name,
user: payload.profileId,
data: payload.properties,
timestamp: new Date(payload.createdAt).toISOString(),
location: \`\${payload.city}, \${payload.country}\`
})`}
</pre>
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
<strong>Security:</strong> Network calls, file system
access, and other dangerous operations are blocked.
</p>
</div>
}
>
<JsonEditor
value={field.value ?? DEFAULT_TRANSFORMER}
onChange={(value) => {
field.onChange(value);
// Clear error when user starts typing
if (form.formState.errors.config?.javascriptTemplate) {
form.clearErrors('config.javascriptTemplate');
}
}}
placeholder={DEFAULT_TRANSFORMER}
minHeight="300px"
language="javascript"
/>
{form.formState.errors.config?.javascriptTemplate && (
<p className="mt-1 text-sm text-destructive">
{form.formState.errors.config.javascriptTemplate.message}
</p>
)}
</WithLabel>
)}
/>
)}
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
</form>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import {
Compartment,
EditorState,
type Extension,
} from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from './theme-provider';
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
language?: 'json' | 'javascript';
onValidate?: (isValid: boolean, error?: string) => void;
}
export function JsonEditor({
value,
onChange,
placeholder = '{}',
className = '',
minHeight = '200px',
language = 'json',
onValidate,
}: JsonEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartmentRef = useRef<Compartment | null>(null);
const languageCompartmentRef = useRef<Compartment | null>(null);
const { appTheme } = useTheme();
const [isValid, setIsValid] = useState(true);
const [error, setError] = useState<string | undefined>();
const isUpdatingRef = useRef(false);
const validateContent = (content: string) => {
if (!content.trim()) {
setIsValid(true);
setError(undefined);
onValidate?.(true);
return;
}
if (language === 'json') {
try {
JSON.parse(content);
setIsValid(true);
setError(undefined);
onValidate?.(true);
} catch (e) {
setIsValid(false);
const errorMsg =
e instanceof Error ? e.message : 'Invalid JSON syntax';
setError(errorMsg);
onValidate?.(false, errorMsg);
}
} else if (language === 'javascript') {
// No frontend validation for JavaScript - validation happens in tRPC
setIsValid(true);
setError(undefined);
onValidate?.(true);
}
};
// Create editor once on mount
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
const themeCompartment = new Compartment();
themeCompartmentRef.current = themeCompartment;
const languageCompartment = new Compartment();
languageCompartmentRef.current = languageCompartment;
const extensions: Extension[] = [
basicSetup,
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
EditorState.tabSize.of(2),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
isUpdatingRef.current = true;
const newValue = update.state.doc.toString();
onChange(newValue);
validateContent(newValue);
// Reset flag after a short delay
setTimeout(() => {
isUpdatingRef.current = false;
}, 0);
}
}),
EditorView.theme({
'&': {
fontSize: '14px',
minHeight,
maxHeight: '400px',
},
'&.cm-editor': {
borderRadius: '6px',
border: `1px solid ${
isValid ? 'hsl(var(--border))' : 'hsl(var(--destructive))'
}`,
overflow: 'hidden',
},
'.cm-scroller': {
minHeight,
maxHeight: '400px',
overflow: 'auto',
},
'.cm-content': {
padding: '12px 12px 12px 0',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
minHeight,
},
'.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'hsl(var(--muted))',
borderRight: '1px solid hsl(var(--border))',
paddingLeft: '8px',
},
'.cm-lineNumbers .cm-gutterElement': {
color: 'hsl(var(--muted-foreground))',
paddingRight: '12px',
paddingLeft: '4px',
},
}),
themeCompartment.of(appTheme === 'dark' ? [oneDark] : []),
];
const state = EditorState.create({
doc: value,
extensions,
});
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// Initial validation
validateContent(value);
return () => {
view.destroy();
viewRef.current = null;
themeCompartmentRef.current = null;
};
}, []); // Only create once
// Update theme using compartment
useEffect(() => {
if (!viewRef.current || !themeCompartmentRef.current) return;
viewRef.current.dispatch({
effects: themeCompartmentRef.current.reconfigure(
appTheme === 'dark' ? [oneDark] : [],
),
});
}, [appTheme]);
// Update language using compartment
useEffect(() => {
if (!viewRef.current || !languageCompartmentRef.current) return;
viewRef.current.dispatch({
effects: languageCompartmentRef.current.reconfigure(
language === 'javascript' ? [javascript()] : [json()],
),
});
validateContent(value);
}, [language, value]);
// Update editor content when value changes externally
useEffect(() => {
if (!viewRef.current || isUpdatingRef.current) return;
const currentContent = viewRef.current.state.doc.toString();
if (currentContent !== value) {
viewRef.current.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
// Validate after external update
validateContent(value);
}
}, [value]);
return (
<div className={className}>
<div
ref={editorRef}
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
/>
{!isValid && (
<p className="mt-1 text-sm text-destructive">
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
</p>
)}
</div>
);
}

View File

@@ -149,7 +149,7 @@ export function useColumns() {
}
return (
<ProjectLink
href={`/profiles/${event.profileId}`}
href={`/profiles/${encodeURIComponent(event.profileId)}`}
className="inline-flex min-w-full flex-none items-center gap-2"
>
{event.profileId}

View File

@@ -4,7 +4,8 @@ import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import type React from 'react';
import { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
@@ -106,7 +107,9 @@ export function OverviewListModal<T extends OverviewListItem>({
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
const maxSessions = Math.max(
...filteredData.map((item) => item.sessions),
);
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
@@ -152,7 +155,8 @@ export function OverviewListModal<T extends OverviewListItem>({
<div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
gridTemplateColumns:
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
@@ -204,11 +208,14 @@ export function OverviewListModal<T extends OverviewListItem>({
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
gridTemplateColumns:
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
<div className="min-w-0 truncate pr-2">
{renderItem(item)}
</div>
{/* Revenue cell */}
{hasRevenue && (
@@ -261,4 +268,3 @@ export function OverviewListModal<T extends OverviewListItem>({
</ModalContent>
);
}

View File

@@ -207,7 +207,7 @@ export function OverviewMetricCardNumber({
isLoading?: boolean;
}) {
return (
<div className={cn('min-w-0 col gap-2 items-start', className)}>
<div className={cn('min-w-0 col gap-2', className)}>
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
@@ -219,7 +219,7 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
{value}
</div>
)}

View File

@@ -351,7 +351,7 @@ export default function OverviewTopDevices({
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -118,12 +118,12 @@ export default function OverviewTopEvents({
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData.slice(0, 15);
return tableData;
}
const queryLower = searchQuery.toLowerCase();
return tableData
.filter((item) => item.name?.toLowerCase().includes(queryLower))
.slice(0, 15);
return tableData.filter((item) =>
item.name?.toLowerCase().includes(queryLower),
);
}, [tableData, searchQuery]);
const tabs = useMemo(

View File

@@ -89,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
);
const filteredData = useMemo(() => {
const data = query.data?.slice(0, 15) ?? [];
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -97,7 +97,7 @@ export default function OverviewTopSources({
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -2,7 +2,8 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
@@ -45,6 +46,42 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
);
}
function SortableHeader({
name,
isSorted,
sortDirection,
onClick,
isRightAligned,
}: {
name: string;
isSorted: boolean;
sortDirection: 'asc' | 'desc' | null;
onClick: () => void;
isRightAligned?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'row items-center gap-1 hover:opacity-80 transition-opacity',
isRightAligned && 'justify-end ml-auto',
)}
>
<span>{name}</span>
{isSorted ? (
sortDirection === 'desc' ? (
<ChevronDown className="size-3" />
) : (
<ChevronUp className="size-3" />
)
) : (
<ChevronDown className="size-3 opacity-30" />
)}
</button>
);
}
type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number;
};
@@ -56,10 +93,113 @@ export const OverviewWidgetTable = <T,>({
getColumnPercentage,
className,
}: Props<T>) => {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(
null,
);
// Handle column header click for sorting
const handleSort = (columnName: string) => {
if (sortColumn === columnName) {
// Cycle through: desc -> asc -> null
if (sortDirection === 'desc') {
setSortDirection('asc');
} else if (sortDirection === 'asc') {
setSortColumn(null);
setSortDirection(null);
}
} else {
// First click on a column = descending (highest to lowest)
setSortColumn(columnName);
setSortDirection('desc');
}
};
// Sort data based on current sort state
// Sort all available items, then limit display to top 15
const sortedData = useMemo(() => {
const allData = data ?? [];
if (!sortColumn || !sortDirection) {
// When not sorting, return top 15 (maintain original behavior)
return allData;
}
const column = columns.find((col) => {
if (typeof col.name === 'string') {
return col.name === sortColumn;
}
return false;
});
if (!column?.getSortValue) {
return allData;
}
// Sort all available items
const sorted = [...allData].sort((a, b) => {
const aValue = column.getSortValue!(a);
const bValue = column.getSortValue!(b);
// Handle null values
if (aValue === null && bValue === null) return 0;
if (aValue === null) return 1;
if (bValue === null) return -1;
// Compare values
let comparison = 0;
if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return sortDirection === 'desc' ? -comparison : comparison;
});
return sorted;
}, [data, sortColumn, sortDirection, columns]).slice(0, 15);
// Create columns with sortable headers
const columnsWithSortableHeaders = useMemo(() => {
return columns.map((column, index) => {
const columnName =
typeof column.name === 'string' ? column.name : String(column.name);
const isSortable = !!column.getSortValue;
const isSorted = sortColumn === columnName;
const currentSortDirection = isSorted ? sortDirection : null;
const isRightAligned = index !== 0;
return {
...column,
// Add a key property for React keys (using the original column name string)
key: columnName,
name: isSortable ? (
<SortableHeader
name={columnName}
isSorted={isSorted}
sortDirection={currentSortDirection}
onClick={() => handleSort(columnName)}
isRightAligned={isRightAligned}
/>
) : (
column.name
),
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
// Remove old responsive logic - now handled by responsive prop
column.className,
),
};
});
}, [columns, sortColumn, sortDirection]);
return (
<div className={cn(className)}>
<WidgetTable
data={data ?? []}
data={sortedData}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
@@ -75,18 +215,7 @@ export const OverviewWidgetTable = <T,>({
</div>
);
}}
columns={columns.map((column, index) => {
return {
...column,
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
// Remove old responsive logic - now handled by responsive prop
column.className,
),
};
})}
columns={columnsWithSortableHeaders}
/>
</div>
);
@@ -208,6 +337,8 @@ export function OverviewWidgetTablePages({
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) =>
item.revenue ?? 0,
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -231,6 +362,7 @@ export function OverviewWidgetTablePages({
name: 'Views',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.pageviews,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -245,6 +377,7 @@ export function OverviewWidgetTablePages({
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.sessions,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -339,6 +472,8 @@ export function OverviewWidgetTableEntries({
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) =>
item.revenue ?? 0,
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -362,6 +497,7 @@ export function OverviewWidgetTableEntries({
name: lastColumnName,
width: '84px',
responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.sessions,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -494,6 +630,9 @@ export function OverviewWidgetTableGeneric({
name: 'Revenue',
width: '100px',
responsive: { priority: 3 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.revenue ?? 0,
render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -521,6 +660,9 @@ export function OverviewWidgetTableGeneric({
name: 'Views',
width: '84px',
responsive: { priority: 2 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.pageviews,
render(item: RouterOutputs['overview']['topGeneric'][number]) {
return (
<div className="row gap-2 justify-end">
@@ -537,6 +679,9 @@ export function OverviewWidgetTableGeneric({
name: 'Sess.',
width: '84px',
responsive: { priority: 2 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.sessions,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -599,6 +744,7 @@ export function OverviewWidgetTableEvents({
name: 'Count',
width: '84px',
responsive: { priority: 2 },
getSortValue: (item: EventTableItem) => item.count,
render(item) {
return (
<div className="row gap-2 justify-end">

View File

@@ -7,6 +7,7 @@ import {
format,
formatISO,
isSameMonth,
isToday,
startOfMonth,
subMonths,
} from 'date-fns';
@@ -18,15 +19,26 @@ import {
WidgetTitle,
} from '../overview/overview-widget';
import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
type Props = {
data: { count: number; date: string }[];
};
function getOpacityLevel(count: number, maxCount: number): number {
if (count === 0 || maxCount === 0) return 0;
const ratio = count / maxCount;
if (ratio <= 0.25) return 0.25;
if (ratio <= 0.5) return 0.5;
if (ratio <= 0.75) return 0.75;
return 1;
}
const MonthCalendar = ({
month,
data,
}: { month: Date; data: Props['data'] }) => (
maxCount,
}: { month: Date; data: Props['data']; maxCount: number }) => (
<div>
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
@@ -37,14 +49,42 @@ const MonthCalendar = ({
const hit = data.find((item) =>
item.date.includes(formatISO(date, { representation: 'date' })),
);
const opacity = hit ? getOpacityLevel(hit.count, maxCount) : 0;
return (
<div
<Tooltiper
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
asChild
content={
<div className="text-sm col gap-1">
<div className="font-medium">{format(date, 'EEEE, MMM d')}</div>
{hit ? (
<div className="text-muted-foreground">
{hit.count} {hit.count === 1 ? 'event' : 'events'}
</div>
) : (
<div className="text-muted-foreground">No activity</div>
)}
</div>
}
>
<div
className={cn(
'aspect-square w-full rounded cursor-default group hover:ring-1 hover:ring-foreground overflow-hidden',
)}
>
<div
className={cn(
'size-full group-hover:shadow-[inset_0_0_0_2px_var(--background)] rounded',
isToday(date)
? 'bg-highlight'
: hit
? 'bg-foreground'
: 'bg-def-200',
)}
style={hit && !isToday(date) ? { opacity } : undefined}
/>
</div>
</Tooltiper>
);
})}
</div>
@@ -53,6 +93,7 @@ const MonthCalendar = ({
export const ProfileActivity = ({ data }: Props) => {
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
const maxCount = Math.max(...data.map((item) => item.count), 0);
return (
<Widget className="w-full">
@@ -83,6 +124,7 @@ export const ProfileActivity = ({ data }: Props) => {
key={offset}
month={subMonths(startDate, offset)}
data={data}
maxCount={maxCount}
/>
))}
</div>

View File

@@ -20,7 +20,7 @@ export function useColumns(type: 'profiles' | 'power-users') {
const profile = row.original;
return (
<ProjectLink
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="flex items-center gap-2 font-medium"
title={getProfileName(profile, false)}
>

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,18 +11,12 @@ import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -12,18 +11,12 @@ import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -14,18 +13,11 @@ import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit);
const res = useQuery(
trpc.chart.conversion.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,18 +11,12 @@ import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,8 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -13,18 +11,11 @@ import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,18 +11,12 @@ import { Chart } from './chart';
export function ReportMapChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -11,18 +10,12 @@ import { Chart } from './chart';
export function ReportMetricChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,18 +11,12 @@ import { Chart } from './chart';
export function ReportPieChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -53,7 +53,7 @@ export function ReportRetentionChart() {
criteria,
interval: overviewInterval ?? interval,
shareId,
reportId: id,
id,
},
{
placeholderData: keepPreviousData,

View File

@@ -62,7 +62,7 @@ export function useColumns() {
if (session.profile) {
return (
<ProjectLink
href={`/profiles/${session.profile.id}`}
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
className="font-medium"
>
{getProfileName(session.profile)}
@@ -71,7 +71,7 @@ export function useColumns() {
}
return (
<ProjectLink
href={`/profiles/${session.profileId}`}
href={`/profiles/${encodeURIComponent(session.profileId)}`}
className="font-mono font-medium"
>
{session.profileId}

View File

@@ -29,6 +29,14 @@ export interface Props<T> {
* If not provided, column is always visible.
*/
responsive?: ColumnResponsive;
/**
* Function to extract sortable value. If provided, header becomes clickable.
*/
getSortValue?: (item: T) => number | string | null;
/**
* Optional key for React keys. If not provided, will try to extract from name or use index.
*/
key?: string;
}[];
keyExtractor: (item: T) => string;
data: T[];
@@ -177,9 +185,16 @@ export function WidgetTable<T>({
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
}
// Use column.key if available, otherwise try to extract string from name, fallback to index
const columnKey =
column.key ??
(typeof column.name === 'string'
? column.name
: `col-${colIndex}`);
return (
<div
key={column.name?.toString()}
key={columnKey}
className={cn(
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
columns.length > 1 && column !== columns[0]
@@ -231,9 +246,16 @@ export function WidgetTable<T>({
);
}
// Use column.key if available, otherwise try to extract string from name, fallback to index
const columnKey =
column.key ??
(typeof column.name === 'string'
? column.name
: `col-${colIndex}`);
return (
<div
key={column.name?.toString()}
key={columnKey}
className={cn(
'px-2 relative cell',
columns.length > 1 && column !== columns[0]

View File

@@ -232,7 +232,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
{profile && (
<ProjectLink
onClick={() => popModal()}
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="card p-4 py-2 col gap-2 hover:bg-def-100"
>
<div className="row items-center gap-2 justify-between">

View File

@@ -25,7 +25,7 @@ const ProfileItem = ({ profile }: { profile: any }) => {
return (
<ProjectLink
preload={false}
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
title={getProfileName(profile, false)}
className="col gap-2 rounded-lg border p-2 bg-card"
onClick={(e) => {

View File

@@ -20,6 +20,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as WidgetTestRouteImport } from './routes/widget/test'
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
import { Route as WidgetBadgeRouteImport } from './routes/widget/badge'
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
import { Route as ApiConfigRouteImport } from './routes/api/config'
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
@@ -150,6 +151,11 @@ const WidgetCounterRoute = WidgetCounterRouteImport.update({
path: '/widget/counter',
getParentRoute: () => rootRouteImport,
} as any)
const WidgetBadgeRoute = WidgetBadgeRouteImport.update({
id: '/widget/badge',
path: '/widget/badge',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
id: '/api/healthcheck',
path: '/api/healthcheck',
@@ -567,6 +573,7 @@ export interface FileRoutesByFullPath {
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -636,6 +643,7 @@ export interface FileRoutesByTo {
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -702,6 +710,7 @@ export interface FileRoutesById {
'/_public/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -782,6 +791,7 @@ export interface FileRouteTypes {
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -916,6 +927,7 @@ export interface FileRouteTypes {
| '/_public/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -995,6 +1007,7 @@ export interface RootRouteChildren {
UnsubscribeRoute: typeof UnsubscribeRoute
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetBadgeRoute: typeof WidgetBadgeRoute
WidgetCounterRoute: typeof WidgetCounterRoute
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
WidgetTestRoute: typeof WidgetTestRoute
@@ -1068,6 +1081,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WidgetCounterRouteImport
parentRoute: typeof rootRouteImport
}
'/widget/badge': {
id: '/widget/badge'
path: '/widget/badge'
fullPath: '/widget/badge'
preLoaderRoute: typeof WidgetBadgeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/healthcheck': {
id: '/api/healthcheck'
path: '/api/healthcheck'
@@ -2005,6 +2025,7 @@ const rootRouteChildren: RootRouteChildren = {
UnsubscribeRoute: UnsubscribeRoute,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetBadgeRoute: WidgetBadgeRoute,
WidgetCounterRoute: WidgetCounterRoute,
WidgetRealtimeRoute: WidgetRealtimeRoute,
WidgetTestRoute: WidgetTestRoute,

View File

@@ -107,6 +107,11 @@ function Component() {
isToggling={toggleMutation.isPending}
onToggle={(enabled) => handleToggle('counter', enabled)}
/>
<BadgeWidgetSection
widget={counterWidget as any}
dashboardUrl={dashboardUrl}
/>
</div>
);
}
@@ -369,3 +374,96 @@ function CounterWidgetSection({
</Widget>
);
}
interface BadgeWidgetSectionProps {
widget: {
id: string;
public: boolean;
} | null;
dashboardUrl: string;
}
function BadgeWidgetSection({ widget, dashboardUrl }: BadgeWidgetSectionProps) {
const isEnabled = widget?.public ?? false;
const badgeUrl =
isEnabled && widget?.id
? `${dashboardUrl}/widget/badge?shareId=${widget.id}`
: null;
const badgeEmbedCode = badgeUrl
? `<a href="https://openpanel.dev" style="display: inline-block; overflow: hidden; border-radius: 8px;">
<iframe src="${badgeUrl}" height="48" width="250" style="border: none; overflow: hidden; pointer-events: none;" title="OpenPanel Analytics Badge"></iframe>
</a>`
: null;
if (!isEnabled || !badgeUrl) {
return null;
}
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="row items-center justify-between gap-6">
<div className="space-y-2">
<span className="title">Analytics Badge</span>
<p className="text-muted-foreground">
A Product Hunt-style badge showing your 30-day unique visitor count.
Perfect for showcasing your analytics powered by OpenPanel.
</p>
</div>
</WidgetHead>
<WidgetBody className="space-y-6">
<div className="space-y-2">
<h3 className="text-sm font-medium">Widget URL</h3>
<CopyInput label="" value={badgeUrl} className="w-full" />
<p className="text-xs text-muted-foreground">
Direct link to the analytics badge widget.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Embed Code</h3>
<Syntax code={badgeEmbedCode!} language="bash" />
<p className="text-xs text-muted-foreground">
Copy this code and paste it into your website HTML where you want
the badge to appear.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Preview</h3>
<div className="border rounded-lg p-4 bg-muted/30">
<a
href="https://openpanel.dev"
target="_blank"
rel="noopener noreferrer"
style={{
overflow: 'hidden',
borderRadius: '8px',
display: 'inline-block',
}}
>
<iframe
src={badgeUrl}
height="48"
width="250"
className="border-0 pointer-events-none"
title="Analytics Badge Preview"
/>
</a>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
icon={ExternalLinkIcon}
onClick={() =>
window.open(badgeUrl, '_blank', 'noopener,noreferrer')
}
>
Open in new tab
</Button>
</div>
</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -4,6 +4,7 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart';
import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query';
@@ -63,6 +64,7 @@ function RouteComponent() {
const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/report/$shareId' });
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const shareQuery = useSuspenseQuery(
trpc.share.report.queryOptions({
shareId,
@@ -81,8 +83,6 @@ function RouteComponent() {
const share = shareQuery.data;
console.log('share', share);
// Handle password protection
if (share.password && !hasAccess) {
return <ShareEnterPassword shareId={share.id} shareType="report" />;
@@ -114,7 +114,16 @@ function RouteComponent() {
<div className="font-medium text-xl">{share.report.name}</div>
</div>
<div className="p-4">
<ReportChart report={share.report} shareId={shareId} />
<ReportChart
report={{
...share.report,
range: range ?? share.report.range,
startDate: startDate ?? share.report.startDate,
endDate: endDate ?? share.report.endDate,
interval: interval ?? share.report.interval,
}}
shareId={shareId}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import { LogoSquare } from '@/components/logo';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import { z } from 'zod';
const widgetSearchSchema = z.object({
shareId: z.string(),
color: z.string().optional(),
});
export const Route = createFileRoute('/widget/badge')({
component: RouteComponent,
validateSearch: widgetSearchSchema,
});
function RouteComponent() {
const { shareId, color } = Route.useSearch();
const trpc = useTRPC();
// Fetch widget data
const { data, isLoading } = useQuery(
trpc.widget.badge.queryOptions({ shareId }),
);
if (isLoading) {
return <BadgeWidget visitors={0} isLoading color={color} />;
}
if (!data) {
return <BadgeWidget visitors={0} color={color} />;
}
return <BadgeWidget visitors={data.visitors} color={color} />;
}
interface BadgeWidgetProps {
visitors: number;
isLoading?: boolean;
color?: string;
}
function BadgeWidget({ visitors, isLoading, color }: BadgeWidgetProps) {
const number = useNumber();
return (
<div
className="absolute inset-0 group inline-flex items-center gap-3 rounded-lg center-center px-2"
style={{
backgroundColor: color,
}}
>
{/* Logo on the left */}
<div className="flex-shrink-0">
<LogoSquare className="h-8 w-8" />
</div>
{/* Center text */}
<div className="flex flex-col gap-0.5 flex-1 min-w-0 items-start -mt-px">
<div className="text-[10px] font-medium uppercase tracking-wide text-white/80">
ANALYTICS FROM
</div>
<div className="font-semibold text-white leading-tight">OpenPanel</div>
</div>
{/* Visitor count on the right */}
<div className="col center-center flex-shrink-0 gap-1">
<UsersIcon className="size-4 text-white" />
<div className="text-sm font-medium text-white tabular-nums">
{isLoading ? <span>...</span> : number.short(visitors)}
</div>
</div>
</div>
);
}

View File

@@ -36,9 +36,11 @@ COPY packages/queue/package.json ./packages/queue/
COPY packages/logger/package.json ./packages/logger/
COPY packages/common/package.json ./packages/common/
COPY packages/importer/package.json ./packages/importer/
COPY packages/payments/package.json ./packages/payments/
COPY packages/constants/package.json ./packages/constants/
COPY packages/validation/package.json ./packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/integrations/package.json ./packages/integrations/
COPY packages/js-runtime/package.json ./packages/js-runtime/
COPY patches ./patches
# BUILD
@@ -85,8 +87,11 @@ COPY --from=build /app/packages/queue ./packages/queue
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/importer ./packages/importer
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen

View File

@@ -17,6 +17,7 @@
"@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/js-runtime": "workspace:*",
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/importer": "workspace:*",

View File

@@ -1,10 +1,6 @@
import { logger as baseLogger } from '@/utils/logger';
import {
createSessionEndJob,
createSessionStart,
getSessionEnd,
} from '@/utils/session-handler';
import { isSameDomain, parsePath } from '@openpanel/common';
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
import { getTime, isSameDomain, parsePath } from '@openpanel/common';
import {
getReferrerWithQuery,
parseReferrer,
@@ -193,7 +189,14 @@ export async function incomingEvent(
if (!sessionEnd) {
logger.info('Creating session start event', { event: payload });
await createSessionStart({ payload }).catch((error) => {
await createEventAndNotify(
{
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
},
logger,
).catch((error) => {
logger.error('Error creating session start event', { event: payload });
throw error;
});

View File

@@ -1,11 +1,22 @@
import type { Job } from 'bullmq';
import { db } from '@openpanel/db';
import { Prisma, db } from '@openpanel/db';
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
import { setSuperJson } from '@openpanel/json';
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
import type { NotificationQueuePayload } from '@openpanel/queue';
import { getRedisPub, publishEvent } from '@openpanel/redis';
import { publishEvent } from '@openpanel/redis';
function isValidJson<T>(
value: T | Prisma.NullableJsonNullValueInput | null | undefined,
): value is T {
return (
value !== null &&
value !== undefined &&
value !== Prisma.JsonNull &&
value !== Prisma.DbNull
);
}
export async function notificationJob(job: Job<NotificationQueuePayload>) {
switch (job.data.type) {
@@ -14,12 +25,10 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
if (notification.sendToApp) {
publishEvent('notification', 'created', notification);
// empty for now
return;
}
if (notification.sendToEmail) {
// empty for now
return;
}
@@ -33,18 +42,44 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
},
});
const payload = notification.payload;
if (!isValidJson(payload)) {
return new Error('Invalid payload');
}
switch (integration.config.type) {
case 'webhook': {
let body: unknown;
if (integration.config.mode === 'javascript') {
// We only transform event payloads for now (not funnel)
if (
integration.config.javascriptTemplate &&
payload.type === 'event'
) {
const result = executeJavaScriptTemplate(
integration.config.javascriptTemplate,
payload.event,
);
body = result;
} else {
body = payload;
}
} else {
body = {
title: notification.title,
message: notification.message,
};
}
return fetch(integration.config.url, {
method: 'POST',
headers: {
...(integration.config.headers ?? {}),
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: notification.title,
message: notification.message,
}),
body: JSON.stringify(body),
});
}
case 'discord': {

View File

@@ -12,18 +12,6 @@ export const SESSION_TIMEOUT = 1000 * 60 * 30;
const getSessionEndJobId = (projectId: string, deviceId: string) =>
`sessionEnd:${projectId}:${deviceId}`;
export async function createSessionStart({
payload,
}: {
payload: IServiceCreateEventPayload;
}) {
return createEvent({
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
});
}
export async function createSessionEndJob({
payload,
}: {

View File

@@ -109,7 +109,9 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
interval: zTimeInterval,
});
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
export type IGetTopGenericSeriesInput = z.infer<
typeof zGetTopGenericSeriesInput
> & {
timezone: string;
};
@@ -734,7 +736,7 @@ export class OverviewService {
}>;
}> {
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
const TOP_LIMIT = 15;
const TOP_LIMIT = 500;
const fillConfig = this.getFillConfig(interval, startDate, endDate);
// Step 1: Get top 15 items

View File

@@ -1,5 +1,6 @@
import { Button as EmailButton } from '@react-email/components';
import type * as React from 'react';
// biome-ignore lint/style/useImportType: <explanation>
import React from 'react';
export function Button({
href,

View File

@@ -7,7 +7,8 @@ import {
Section,
Tailwind,
} from '@react-email/components';
import type React from 'react';
// biome-ignore lint/style/useImportType: <explanation>
import React from 'react';
import { Footer } from './footer';
type Props = {

View File

@@ -1,4 +1,6 @@
import { Text } from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation>
import React from 'react';
export function List({ items }: { items: React.ReactNode[] }) {
return (

View File

@@ -0,0 +1 @@
export * from './src/index';

View File

@@ -0,0 +1,21 @@
{
"name": "@openpanel/js-runtime",
"version": "0.0.1",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts"
},
"scripts": {
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@babel/parser": "^7.26.0"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"typescript": "catalog:",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,165 @@
import { ALLOWED_GLOBALS, ALLOWED_INSTANCE_METHODS, ALLOWED_METHODS } from './constants';
/**
* Simple recursive AST walker that doesn't require @babel/traverse
*/
export function walkNode(
node: unknown,
visitor: (
node: Record<string, unknown>,
parent?: Record<string, unknown>,
) => void,
parent?: Record<string, unknown>,
): void {
if (!node || typeof node !== 'object') {
return;
}
// Handle arrays
if (Array.isArray(node)) {
for (const child of node) {
walkNode(child, visitor, parent);
}
return;
}
const nodeObj = node as Record<string, unknown>;
// Only visit AST nodes (they have a 'type' property)
if (typeof nodeObj.type === 'string') {
visitor(nodeObj, parent);
}
// Recursively walk all properties
for (const key of Object.keys(nodeObj)) {
const value = nodeObj[key];
if (value && typeof value === 'object') {
walkNode(value, visitor, nodeObj);
}
}
}
/**
* Track declared variables/parameters to know what identifiers are "local"
*/
export function collectDeclaredIdentifiers(ast: unknown): Set<string> {
const declared = new Set<string>();
walkNode(ast, (node) => {
// Variable declarations: const x = ..., let y = ..., var z = ...
if (node.type === 'VariableDeclarator') {
const id = node.id as Record<string, unknown>;
if (id.type === 'Identifier') {
declared.add(id.name as string);
}
// Handle destructuring patterns
if (id.type === 'ObjectPattern') {
collectPatternIdentifiers(id, declared);
}
if (id.type === 'ArrayPattern') {
collectPatternIdentifiers(id, declared);
}
}
// Function parameters
if (
node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'FunctionDeclaration'
) {
const params = node.params as Array<Record<string, unknown>>;
for (const param of params) {
collectPatternIdentifiers(param, declared);
}
}
});
return declared;
}
/**
* Collect identifiers from destructuring patterns
*/
function collectPatternIdentifiers(
pattern: Record<string, unknown>,
declared: Set<string>,
): void {
if (pattern.type === 'Identifier') {
declared.add(pattern.name as string);
} else if (pattern.type === 'ObjectPattern') {
const properties = pattern.properties as Array<Record<string, unknown>>;
for (const prop of properties) {
if (prop.type === 'ObjectProperty') {
collectPatternIdentifiers(
prop.value as Record<string, unknown>,
declared,
);
} else if (prop.type === 'RestElement') {
collectPatternIdentifiers(
prop.argument as Record<string, unknown>,
declared,
);
}
}
} else if (pattern.type === 'ArrayPattern') {
const elements = pattern.elements as Array<Record<string, unknown> | null>;
for (const elem of elements) {
if (elem) {
collectPatternIdentifiers(elem, declared);
}
}
} else if (pattern.type === 'RestElement') {
collectPatternIdentifiers(
pattern.argument as Record<string, unknown>,
declared,
);
} else if (pattern.type === 'AssignmentPattern') {
// Default parameter values: (x = 5) => ...
collectPatternIdentifiers(
pattern.left as Record<string, unknown>,
declared,
);
}
}
/**
* Check if an identifier is used as a property key (not a value reference)
*/
export function isPropertyKey(
node: Record<string, unknown>,
parent?: Record<string, unknown>,
): boolean {
if (!parent) return false;
// Property in object literal: { foo: value } - foo is a key
if (
parent.type === 'ObjectProperty' &&
parent.key === node &&
!parent.computed
) {
return true;
}
// Property access: obj.foo - foo is a property access, not a global reference
if (
parent.type === 'MemberExpression' &&
parent.property === node &&
!parent.computed
) {
return true;
}
// Optional chaining: obj?.foo - foo is a property access
if (
parent.type === 'OptionalMemberExpression' &&
parent.property === node &&
!parent.computed
) {
return true;
}
// Arrow function parameter used in callback: t => t.toUpperCase()
// The 't' in arrow function is already collected as declared identifier
return false;
}

View File

@@ -0,0 +1,141 @@
/**
* Allowed global identifiers - super restricted allowlist
*/
export const ALLOWED_GLOBALS = new Set([
// Basic values for comparisons
'undefined',
'null',
// Type coercion functions
'parseInt',
'parseFloat',
'isNaN',
'isFinite',
// Safe built-in objects (for static methods only)
'Math',
'Date',
'JSON',
]);
/**
* Allowed methods on built-in objects (static methods)
*/
export const ALLOWED_METHODS: Record<string, Set<string>> = {
// Math methods
Math: new Set(['abs', 'ceil', 'floor', 'round', 'min', 'max', 'random']),
// JSON methods
JSON: new Set(['parse', 'stringify']),
// Date static methods
Date: new Set(['now']),
};
/**
* Allowed instance methods (methods called on values, not on global objects)
* These are safe methods that can be called on any value
*/
export const ALLOWED_INSTANCE_METHODS = new Set([
// Array instance methods
'map',
'filter',
'reduce',
'reduceRight',
'forEach',
'find',
'findIndex',
'findLast',
'findLastIndex',
'some',
'every',
'includes',
'indexOf',
'lastIndexOf',
'slice',
'concat',
'join',
'flat',
'flatMap',
'sort',
'reverse',
'fill',
'at',
'with',
'toSorted',
'toReversed',
'toSpliced',
// String instance methods
'toLowerCase',
'toUpperCase',
'toLocaleLowerCase',
'toLocaleUpperCase',
'trim',
'trimStart',
'trimEnd',
'padStart',
'padEnd',
'repeat',
'replace',
'replaceAll',
'split',
'substring',
'substr',
'charAt',
'charCodeAt',
'codePointAt',
'startsWith',
'endsWith',
'match',
'matchAll',
'search',
'normalize',
'localeCompare',
// Number instance methods
'toFixed',
'toPrecision',
'toExponential',
'toLocaleString',
// Date instance methods
'getTime',
'getFullYear',
'getMonth',
'getDate',
'getDay',
'getHours',
'getMinutes',
'getSeconds',
'getMilliseconds',
'getUTCFullYear',
'getUTCMonth',
'getUTCDate',
'getUTCDay',
'getUTCHours',
'getUTCMinutes',
'getUTCSeconds',
'getUTCMilliseconds',
'getTimezoneOffset',
'toISOString',
'toJSON',
'toDateString',
'toTimeString',
'toLocaleDateString',
'toLocaleTimeString',
'valueOf',
// Object instance methods
'hasOwnProperty',
'toString',
'valueOf',
// RegExp instance methods
'test',
'exec',
// Common property access (length, etc.)
'length',
'size',
]);

View File

@@ -0,0 +1,31 @@
/**
* Executes a JavaScript function template
* @param code - JavaScript function code (arrow function or function expression)
* @param payload - Payload object to pass to the function
* @returns The result of executing the function
*/
export function execute(
code: string,
payload: Record<string, unknown>,
): unknown {
try {
// Create the function code that will be executed
// 'use strict' ensures 'this' is undefined (not global object)
const funcCode = `
'use strict';
return (${code})(payload);
`;
// Create function with safe globals in scope
const func = new Function('payload', funcCode);
// Execute the function
return func(payload);
} catch (error) {
throw new Error(
`Error executing JavaScript template: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

View File

@@ -0,0 +1,2 @@
export { validate } from './validate';
export { execute } from './execute';

View File

@@ -0,0 +1,332 @@
import { describe, expect, it } from 'vitest';
import { execute, validate } from './index';
describe('validate', () => {
describe('Valid templates', () => {
it('should accept arrow function', () => {
const result = validate('(payload) => ({ event: payload.name })');
expect(result.valid).toBe(true);
});
it('should reject function expression', () => {
// Function expressions are not allowed - only arrow functions
const result = validate(
'(function(payload) { return { event: payload.name }; })',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('arrow functions');
});
it('should accept complex transformations', () => {
const code = `(payload) => ({
event: payload.name,
user: payload.profileId,
data: payload.properties,
timestamp: new Date(payload.createdAt).toISOString()
})`;
const result = validate(code);
expect(result.valid).toBe(true);
});
it('should accept array operations', () => {
const code = `(payload) => ({
tags: payload.tags?.map(t => t.toUpperCase()) || []
})`;
const result = validate(code);
expect(result.valid).toBe(true);
});
});
describe('Blocked operations', () => {
it('should block fetch calls', () => {
const result = validate('(payload) => fetch("https://evil.com")');
expect(result.valid).toBe(false);
expect(result.error).toContain('fetch');
});
it('should block XMLHttpRequest', () => {
const result = validate('(payload) => new XMLHttpRequest()');
expect(result.valid).toBe(false);
expect(result.error).toContain('XMLHttpRequest');
});
it('should block require calls', () => {
const result = validate('(payload) => require("fs")');
expect(result.valid).toBe(false);
expect(result.error).toContain('require');
});
it('should block import statements', () => {
const result = validate('import fs from "fs"; (payload) => ({})');
expect(result.valid).toBe(false);
// Import statements cause multiple statements error first
expect(result.error).toContain('single function');
});
it('should block eval calls', () => {
const result = validate('(payload) => eval("evil")');
expect(result.valid).toBe(false);
expect(result.error).toContain('eval');
});
it('should block setTimeout', () => {
const result = validate('(payload) => setTimeout(() => {}, 1000)');
expect(result.valid).toBe(false);
expect(result.error).toContain('setTimeout');
});
it('should block process access', () => {
const result = validate('(payload) => process.env.SECRET');
expect(result.valid).toBe(false);
expect(result.error).toContain('process');
});
it('should block while loops', () => {
const result = validate(
'(payload) => { while(true) {} return payload; }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('Loops are not allowed');
});
it('should block for loops', () => {
const result = validate(
'(payload) => { for(let i = 0; i < 10; i++) {} return payload; }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('Loops are not allowed');
});
it('should block try/catch', () => {
const result = validate(
'(payload) => { try { return payload; } catch(e) {} }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('try/catch');
});
it('should block async/await', () => {
const result = validate(
'async (payload) => { await something(); return payload; }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('async/await');
});
it('should block classes', () => {
const result = validate('(payload) => { class Foo {} return payload; }');
expect(result.valid).toBe(false);
expect(result.error).toContain('Class');
});
it('should block new Array()', () => {
const result = validate('(payload) => new Array(10)');
expect(result.valid).toBe(false);
expect(result.error).toContain('new Array()');
});
it('should block new Object()', () => {
const result = validate('(payload) => new Object()');
expect(result.valid).toBe(false);
expect(result.error).toContain('new Object()');
});
it('should allow new Date()', () => {
const result = validate(
'(payload) => ({ timestamp: new Date().toISOString() })',
);
expect(result.valid).toBe(true);
});
});
describe('Invalid syntax', () => {
it('should reject non-function code', () => {
const result = validate('const x = 1;');
expect(result.valid).toBe(false);
expect(result.error).toContain('function');
});
it('should reject invalid JavaScript', () => {
const result = validate('(payload) => { invalid syntax }');
expect(result.valid).toBe(false);
expect(result.error).toContain('Parse error');
});
});
});
describe('execute', () => {
const basePayload = {
name: 'page_view',
profileId: 'user-123',
country: 'US',
city: 'New York',
device: 'desktop',
os: 'Windows',
browser: 'Chrome',
longitude: -73.935242,
latitude: 40.73061,
createdAt: '2024-01-15T10:30:00Z',
properties: {
plan: 'premium',
userId: 'user-456',
metadata: {
source: 'web',
campaign: 'summer-sale',
},
},
profile: {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
},
tags: ['tag1', 'tag2'],
};
describe('Basic transformations', () => {
it('should execute simple arrow function', () => {
const code = '(payload) => ({ event: payload.name })';
const result = execute(code, basePayload);
expect(result).toEqual({ event: 'page_view' });
});
it('should access nested properties', () => {
const code = '(payload) => ({ plan: payload.properties.plan })';
const result = execute(code, basePayload);
expect(result).toEqual({ plan: 'premium' });
});
it('should handle multiple properties', () => {
const code = `(payload) => ({
event: payload.name,
user: payload.profileId,
location: payload.city
})`;
const result = execute(code, basePayload);
expect(result).toEqual({
event: 'page_view',
user: 'user-123',
location: 'New York',
});
});
});
describe('Date operations', () => {
it('should format dates', () => {
const code = `(payload) => ({
timestamp: new Date(payload.createdAt).toISOString()
})`;
const result = execute(code, basePayload);
expect(result).toHaveProperty('timestamp');
expect((result as { timestamp: string }).timestamp).toBe(
'2024-01-15T10:30:00.000Z',
);
});
});
describe('Array operations', () => {
it('should transform arrays', () => {
const code = `(payload) => ({
tags: payload.tags?.map(t => t.toUpperCase()) || []
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ tags: ['TAG1', 'TAG2'] });
});
it('should filter arrays', () => {
const code = `(payload) => ({
filtered: payload.tags?.filter(t => t.includes('tag1')) || []
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ filtered: ['tag1'] });
});
});
describe('String operations', () => {
it('should use template literals', () => {
const code = `(payload) => ({
location: \`\${payload.city}, \${payload.country}\`
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ location: 'New York, US' });
});
it('should use string methods', () => {
const code = `(payload) => ({
upperName: payload.name.toUpperCase()
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ upperName: 'PAGE_VIEW' });
});
});
describe('Math operations', () => {
it('should use Math functions', () => {
const code = `(payload) => ({
roundedLng: Math.round(payload.longitude)
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ roundedLng: -74 });
});
});
describe('Conditional logic', () => {
it('should handle ternary operators', () => {
const code = `(payload) => ({
plan: payload.properties?.plan || 'free'
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ plan: 'premium' });
});
it('should handle if conditions', () => {
const code = `(payload) => {
const result = { event: payload.name };
if (payload.properties?.plan === 'premium') {
result.plan = 'premium';
}
return result;
}`;
const result = execute(code, basePayload);
expect(result).toEqual({ event: 'page_view', plan: 'premium' });
});
});
describe('Complex transformations', () => {
it('should handle nested object construction', () => {
const code = `(payload) => ({
event: payload.name,
user: {
id: payload.profileId,
name: \`\${payload.profile?.firstName} \${payload.profile?.lastName}\`
},
data: payload.properties,
meta: {
location: \`\${payload.city}, \${payload.country}\`,
device: payload.device
}
})`;
const result = execute(code, basePayload);
expect(result).toEqual({
event: 'page_view',
user: {
id: 'user-123',
name: 'John Doe',
},
data: basePayload.properties,
meta: {
location: 'New York, US',
device: 'desktop',
},
});
});
});
describe('Error handling', () => {
it('should handle runtime errors gracefully', () => {
const code = '(payload) => payload.nonexistent.property';
expect(() => {
execute(code, basePayload);
}).toThrow('Error executing JavaScript template');
});
});
});

View File

@@ -0,0 +1,280 @@
import { parse } from '@babel/parser';
import {
ALLOWED_GLOBALS,
ALLOWED_INSTANCE_METHODS,
ALLOWED_METHODS,
} from './constants';
import {
collectDeclaredIdentifiers,
isPropertyKey,
walkNode,
} from './ast-walker';
/**
* Validates that a JavaScript function is safe to execute
* by checking the AST for allowed operations only (allowlist approach)
*/
export function validate(code: string): {
valid: boolean;
error?: string;
} {
if (!code || typeof code !== 'string') {
return { valid: false, error: 'Code must be a non-empty string' };
}
try {
// Parse the code to AST
const ast = parse(code, {
sourceType: 'module',
allowReturnOutsideFunction: true,
plugins: ['typescript'],
});
// Validate root structure: must be exactly one function expression
const program = ast.program;
const body = program.body;
if (body.length === 0) {
return { valid: false, error: 'Code cannot be empty' };
}
if (body.length > 1) {
return {
valid: false,
error:
'Code must contain only a single function. Multiple statements are not allowed.',
};
}
const rootStatement = body[0]!;
// Must be an expression statement containing a function
if (rootStatement.type !== 'ExpressionStatement') {
if (rootStatement.type === 'VariableDeclaration') {
return {
valid: false,
error:
'Variable declarations (const, let, var) are not allowed. Use a direct function expression instead.',
};
}
if (rootStatement.type === 'FunctionDeclaration') {
return {
valid: false,
error:
'Function declarations are not allowed. Use an arrow function or function expression instead: (payload) => { ... } or function(payload) { ... }',
};
}
return {
valid: false,
error: 'Code must be a function expression or arrow function',
};
}
const rootExpression = rootStatement.expression;
if (rootExpression.type !== 'ArrowFunctionExpression') {
if (rootExpression.type === 'FunctionExpression') {
return {
valid: false,
error:
'Function expressions are not allowed. Use arrow functions instead: (payload) => { ... }',
};
}
return {
valid: false,
error: 'Code must be an arrow function, e.g.: (payload) => { ... }',
};
}
// Collect all declared identifiers (variables, parameters)
const declaredIdentifiers = collectDeclaredIdentifiers(ast);
let validationError: string | undefined;
// Walk the AST to check for allowed patterns only
walkNode(ast, (node, parent) => {
// Skip if we already found an error
if (validationError) {
return;
}
// Block import/export declarations
if (
node.type === 'ImportDeclaration' ||
node.type === 'ExportDeclaration'
) {
validationError = 'import/export statements are not allowed';
return;
}
// Block function declarations inside the function body
// (FunctionDeclaration creates a named function, not allowed)
if (node.type === 'FunctionDeclaration') {
validationError =
'Named function declarations are not allowed inside the function body.';
return;
}
// Block loops - use array methods like .map(), .filter() instead
if (
node.type === 'WhileStatement' ||
node.type === 'DoWhileStatement' ||
node.type === 'ForStatement' ||
node.type === 'ForInStatement' ||
node.type === 'ForOfStatement'
) {
validationError =
'Loops are not allowed. Use array methods like .map(), .filter(), .reduce() instead.';
return;
}
// Block advanced/dangerous features
if (node.type === 'TryStatement') {
validationError = 'try/catch statements are not allowed';
return;
}
if (node.type === 'ThrowStatement') {
validationError = 'throw statements are not allowed';
return;
}
if (node.type === 'WithStatement') {
validationError = 'with statements are not allowed';
return;
}
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
validationError = 'Class definitions are not allowed';
return;
}
if (node.type === 'AwaitExpression') {
validationError = 'async/await is not allowed';
return;
}
if (node.type === 'YieldExpression') {
validationError = 'Generators are not allowed';
return;
}
// Block 'this' keyword - arrow functions don't have their own 'this'
// but we block it entirely to prevent any scope leakage
if (node.type === 'ThisExpression') {
validationError =
"'this' keyword is not allowed. Use the payload parameter instead.";
return;
}
// Check identifiers that reference globals
if (node.type === 'Identifier') {
const name = node.name as string;
// Block 'arguments' - not available in arrow functions anyway
// but explicitly block to prevent any confusion
if (name === 'arguments') {
validationError =
"'arguments' is not allowed. Use explicit parameters instead.";
return;
}
// Skip if it's a property key (not a value reference)
if (isPropertyKey(node, parent)) {
return;
}
// Skip if it's a declared local variable/parameter
if (declaredIdentifiers.has(name)) {
return;
}
// Check if it's an allowed global
if (!ALLOWED_GLOBALS.has(name)) {
validationError = `Use of '${name}' is not allowed. Only safe built-in functions are permitted.`;
return;
}
}
// Check method calls on global objects (like Math.random, JSON.parse)
// Handles both regular calls and optional chaining (?.)
if (
node.type === 'CallExpression' ||
node.type === 'OptionalCallExpression'
) {
const callee = node.callee as Record<string, unknown>;
const isMemberExpr =
callee.type === 'MemberExpression' ||
callee.type === 'OptionalMemberExpression';
if (isMemberExpr) {
const obj = callee.object as Record<string, unknown>;
const prop = callee.property as Record<string, unknown>;
const computed = callee.computed as boolean;
// Static method call on global object: Math.random(), JSON.parse()
if (
obj.type === 'Identifier' &&
prop.type === 'Identifier' &&
!computed
) {
const objName = obj.name as string;
const methodName = prop.name as string;
// Check if it's a call on an allowed global object
if (ALLOWED_GLOBALS.has(objName) && ALLOWED_METHODS[objName]) {
if (!ALLOWED_METHODS[objName].has(methodName)) {
validationError = `Method '${objName}.${methodName}' is not allowed. Only safe methods are permitted.`;
return;
}
}
}
// Instance method call: arr.map(), str.toLowerCase(), arr?.map()
// We allow these if the method name is in ALLOWED_INSTANCE_METHODS
if (prop.type === 'Identifier' && !computed) {
const methodName = prop.name as string;
// If calling on something other than an allowed global,
// check if the method is in the allowed instance methods
if (
obj.type !== 'Identifier' ||
!ALLOWED_GLOBALS.has(obj.name as string)
) {
if (!ALLOWED_INSTANCE_METHODS.has(methodName)) {
validationError = `Method '.${methodName}()' is not allowed. Only safe methods are permitted.`;
return;
}
}
}
}
}
// Check 'new' expressions - only allow new Date()
if (node.type === 'NewExpression') {
const callee = node.callee as Record<string, unknown>;
if (callee.type === 'Identifier') {
const name = callee.name as string;
if (name !== 'Date') {
validationError = `'new ${name}()' is not allowed. Only 'new Date()' is permitted.`;
return;
}
}
}
});
if (validationError) {
return { valid: false, error: validationError };
}
return { valid: true };
} catch (error) {
return {
valid: false,
error:
error instanceof Error
? `Parse error: ${error.message}`
: 'Unknown parse error',
};
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,221 @@
import { db } from '@openpanel/db';
import { Polar } from '@polar-sh/sdk';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { getSuccessUrl } from '..';
// Register the autocomplete prompt
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
interface Answers {
isProduction: boolean;
polarApiKey: string;
productId: string;
organizationId: string;
}
async function promptForInput() {
// Get all organizations first
const organizations = await db.organization.findMany({
select: {
id: true,
name: true,
},
});
// Step 1: Collect Polar credentials first
const polarCredentials = await inquirer.prompt<{
isProduction: boolean;
polarApiKey: string;
polarOrganizationId: string;
}>([
{
type: 'list',
name: 'isProduction',
message: 'Is this for production?',
choices: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
},
{
type: 'string',
name: 'polarApiKey',
message: 'Enter your Polar API key:',
validate: (input: string) => {
if (!input) return 'API key is required';
return true;
},
},
]);
// Step 2: Initialize Polar client and fetch products
const polar = new Polar({
accessToken: polarCredentials.polarApiKey,
server: polarCredentials.isProduction ? 'production' : 'sandbox',
});
console.log('Fetching products from Polar...');
const productsResponse = await polar.products.list({
limit: 100,
isArchived: false,
sorting: ['price_amount'],
});
const products = productsResponse.result.items;
if (products.length === 0) {
throw new Error('No products found in Polar');
}
// Step 3: Continue with product selection and organization selection
const restOfAnswers = await inquirer.prompt<{
productId: string;
organizationId: string;
}>([
{
type: 'autocomplete',
name: 'productId',
message: 'Select product:',
source: (answersSoFar: any, input = '') => {
return products
.filter(
(product) =>
product.name.toLowerCase().includes(input.toLowerCase()) ||
product.id.toLowerCase().includes(input.toLowerCase()),
)
.map((product) => {
const price = product.prices?.[0];
const priceStr =
price && 'priceAmount' in price && price.priceAmount
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
: 'No price';
return {
name: `${product.name} (${priceStr})`,
value: product.id,
};
});
},
},
{
type: 'autocomplete',
name: 'organizationId',
message: 'Select organization:',
source: (answersSoFar: any, input = '') => {
return organizations
.filter(
(org) =>
org.name.toLowerCase().includes(input.toLowerCase()) ||
org.id.toLowerCase().includes(input.toLowerCase()),
)
.map((org) => ({
name: `${org.name} (${org.id})`,
value: org.id,
}));
},
},
]);
return {
...polarCredentials,
...restOfAnswers,
};
}
async function main() {
console.log('Assigning existing product to organization...');
const input = await promptForInput();
const polar = new Polar({
accessToken: input.polarApiKey,
server: input.isProduction ? 'production' : 'sandbox',
});
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
id: true,
name: true,
createdBy: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
projects: {
select: {
id: true,
},
},
},
});
if (!organization.createdBy) {
throw new Error(
`Organization ${organization.name} does not have a creator. Cannot proceed.`,
);
}
const user = organization.createdBy;
// Fetch product details for review
const product = await polar.products.get({ id: input.productId });
const price = product.prices?.[0];
const priceStr =
price && 'priceAmount' in price && price.priceAmount
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
: 'No price';
console.log('\nReview the following settings:');
console.table({
product: product.name,
price: priceStr,
organization: organization.name,
email: user.email,
name:
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
});
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message: 'Do you want to proceed?',
default: false,
},
]);
if (!confirmed) {
console.log('Operation canceled');
return;
}
const checkoutLink = await polar.checkoutLinks.create({
paymentProcessor: 'stripe',
productId: input.productId,
allowDiscountCodes: false,
metadata: {
organizationId: organization.id,
userId: user.id,
},
successUrl: getSuccessUrl(
input.isProduction
? 'https://dashboard.openpanel.dev'
: 'http://localhost:3000',
organization.id,
),
});
console.log('\nCheckout link created:');
console.table(checkoutLink);
console.log('\nProduct assigned successfully!');
}
main()
.catch(console.error)
.finally(() => db.$disconnect());

View File

@@ -9,6 +9,7 @@ const options: RedisOptions = {
export { Redis };
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const REDIS_QUEUE_URL = process.env.REDIS_QUEUE_URL || REDIS_URL;
export interface ExtendedRedis extends Redis {
getJson: <T = any>(key: string) => Promise<T | null>;
@@ -74,7 +75,9 @@ export function getRedisCache() {
let redisSub: ExtendedRedis;
export function getRedisSub() {
if (!redisSub) {
redisSub = createRedisClient(REDIS_URL, options);
// In multi-region setup, pub/sub uses central Redis so subscribers
// in any region can receive events published from any region
redisSub = createRedisClient(REDIS_QUEUE_URL, options);
}
return redisSub;
@@ -83,7 +86,9 @@ export function getRedisSub() {
let redisPub: ExtendedRedis;
export function getRedisPub() {
if (!redisPub) {
redisPub = createRedisClient(REDIS_URL, options);
// In multi-region setup, pub/sub uses central Redis so publishers
// in any region can reach subscribers in any region
redisPub = createRedisClient(REDIS_QUEUE_URL, options);
}
return redisPub;
@@ -93,7 +98,8 @@ let redisQueue: ExtendedRedis;
export function getRedisQueue() {
if (!redisQueue) {
// Use different redis for queues (self-hosting will re-use the same redis instance)
redisQueue = createRedisClient(REDIS_URL, {
// In multi-region setup, this points to central EU Redis for queues
redisQueue = createRedisClient(REDIS_QUEUE_URL, {
...options,
enableReadyCheck: false,
maxRetriesPerRequest: null,
@@ -108,7 +114,8 @@ let redisGroupQueue: ExtendedRedis;
export function getRedisGroupQueue() {
if (!redisGroupQueue) {
// Dedicated Redis connection for GroupWorker to avoid blocking BullMQ
redisGroupQueue = createRedisClient(REDIS_URL, {
// In multi-region setup, this points to central EU Redis for queues
redisGroupQueue = createRedisClient(REDIS_QUEUE_URL, {
...options,
enableReadyCheck: false,
maxRetriesPerRequest: null,

View File

@@ -14,6 +14,7 @@
"@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/js-runtime": "workspace:*",
"@openpanel/payments": "workspace:^",
"@openpanel/redis": "workspace:*",
"@openpanel/validation": "workspace:*",

View File

@@ -27,10 +27,10 @@ import {
} from '@openpanel/db';
import {
type IChartEvent,
zReportInput,
zChartSeries,
zCriteria,
zRange,
zReportInput,
zTimeInterval,
} from '@openpanel/validation';
@@ -338,7 +338,7 @@ export const chartRouter = createTRPCRouter({
zReportInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
id: z.string().optional(),
}),
),
)
@@ -347,14 +347,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
if (!input.id) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
input.id,
{
cookies: ctx.cookies,
session: ctx.session?.userId
@@ -367,7 +367,7 @@ export const chartRouter = createTRPCRouter({
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
const report = await getReportById(input.id);
if (!report) {
throw TRPCAccessError('Report not found');
}
@@ -420,7 +420,7 @@ export const chartRouter = createTRPCRouter({
zReportInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
id: z.string().optional(),
}),
),
)
@@ -429,14 +429,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
if (!input.id) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
input.id,
{
cookies: ctx.cookies,
session: ctx.session?.userId
@@ -449,7 +449,7 @@ export const chartRouter = createTRPCRouter({
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
const report = await getReportById(input.id);
if (!report) {
throw TRPCAccessError('Report not found');
}
@@ -549,23 +549,24 @@ export const chartRouter = createTRPCRouter({
zReportInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
id: z.string().optional(),
}),
),
)
.query(async ({ input, ctx }) => {
let chartInput = input;
console.log('input', input);
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
if (!input.id) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
input.id,
ctx,
);
@@ -574,7 +575,7 @@ export const chartRouter = createTRPCRouter({
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
const report = await getReportById(input.id);
if (!report) {
throw TRPCAccessError('Report not found');
}
@@ -609,7 +610,7 @@ export const chartRouter = createTRPCRouter({
zReportInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
id: z.string().optional(),
}),
),
)
@@ -618,14 +619,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
if (!input.id) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
input.id,
{
cookies: ctx.cookies,
session: ctx.session?.userId
@@ -638,7 +639,7 @@ export const chartRouter = createTRPCRouter({
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
const report = await getReportById(input.id);
if (!report) {
throw TRPCAccessError('Report not found');
}
@@ -680,7 +681,7 @@ export const chartRouter = createTRPCRouter({
interval: zTimeInterval.default('day'),
range: zRange,
shareId: z.string().optional(),
reportId: z.string().optional(),
id: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
@@ -695,14 +696,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
if (!input.id) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
input.id,
{
cookies: ctx.cookies,
session: ctx.session?.userId
@@ -715,13 +716,14 @@ export const chartRouter = createTRPCRouter({
}
// Fetch report and extract events
const report = await getReportById(input.reportId);
const report = await getReportById(input.id);
if (!report) {
throw TRPCAccessError('Report not found');
}
projectId = report.projectId;
const retentionOptions = report.options?.type === 'retention' ? report.options : undefined;
const retentionOptions =
report.options?.type === 'retention' ? report.options : undefined;
criteria = retentionOptions?.criteria ?? criteria;
dateRange = input.range ?? report.range;
startDate = input.startDate ?? report.startDate;

View File

@@ -10,8 +10,9 @@ import {
zCreateWebhookIntegration,
} from '@openpanel/validation';
import { getOrganizationAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
export const integrationRouter = createTRPCRouter({
get: protectedProcedure
@@ -93,6 +94,22 @@ export const integrationRouter = createTRPCRouter({
createOrUpdate: protectedProcedure
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
.mutation(async ({ input }) => {
// Validate JavaScript template if mode is javascript
if (
input.config.type === 'webhook' &&
input.config.mode === 'javascript' &&
input.config.javascriptTemplate
) {
const validation = validateJavaScriptTemplate(
input.config.javascriptTemplate,
);
if (!validation.valid) {
throw TRPCBadRequestError(
`Invalid JavaScript template: ${validation.error}`,
);
}
}
if (input.id) {
return db.integration.update({
where: {

View File

@@ -9,6 +9,7 @@ import {
eventBuffer,
getSettingsForProject,
} from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import {
zCounterWidgetOptions,
zRealtimeWidgetOptions,
@@ -144,6 +145,49 @@ export const widgetRouter = createTRPCRouter({
};
}),
badge: publicProcedure
.input(z.object({ shareId: z.string() }))
.query(async ({ input }) => {
const widget = await db.shareWidget.findUnique({
where: {
id: input.shareId,
},
});
if (!widget || !widget.public) {
throw TRPCNotFoundError('Widget not found');
}
if (widget.options.type !== 'counter') {
throw TRPCNotFoundError('Invalid widget type');
}
const { projectId } = widget;
const { timezone } = await getSettingsForProject(projectId);
// Cache for 5 minutes since this queries 30 days of data
const cacheKey = `widget:badge:${projectId}`;
const visitors = await getCache(
cacheKey,
5 * 60, // 5 minutes
async () => {
const uniqueVisitorsQuery = clix(ch, timezone)
.select<{ count: number }>(['uniq(profile_id) as count'])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'));
const result = await uniqueVisitorsQuery.execute();
return result[0]?.count || 0;
},
);
return {
projectId,
visitors,
};
}),
realtimeData: publicProcedure
.input(z.object({ shareId: z.string() }))
.query(async ({ input }) => {

View File

@@ -369,6 +369,8 @@ export const zWebhookConfig = z.object({
url: z.string().url(),
headers: z.record(z.string()),
payload: z.record(z.string(), z.unknown()).optional(),
mode: z.enum(['message', 'javascript']).default('message'),
javascriptTemplate: z.string().optional(),
});
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;

526
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff