Files
stats/packages/validation/src/index.ts
Carl-Gerhard Lindesvärd b421474616 feat: report editor
commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Wed Nov 26 12:32:40 2025 +0100

    wip

commit 8cd3b89fa3
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 22:33:58 2025 +0100

    funnel

commit 95af86dc44
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 22:23:25 2025 +0100

    wip

commit 727a218e6b
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 10:18:26 2025 +0100

    conversion wip

commit 958ba535d6
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 10:18:20 2025 +0100

    wip

commit 3bbeb927cc
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 09:18:48 2025 +0100

    wip

commit d99335e2f4
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 18:08:10 2025 +0100

    wip

commit 1fa61b1ae9
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 15:50:28 2025 +0100

    ts

commit 548747d826
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 13:17:01 2025 +0100

    fix typecheck events -> series

commit 7b18544085
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 13:06:46 2025 +0100

    fix report table

commit 57697a5a39
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Sat Nov 22 00:05:13 2025 +0100

    wip

commit 06fb6c4f3c
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Fri Nov 21 11:21:17 2025 +0100

    wip

commit dd71fd4e11
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Thu Nov 20 13:56:58 2025 +0100

    formulas
2025-11-26 12:33:41 +01:00

556 lines
15 KiB
TypeScript

import { z } from 'zod';
import {
chartSegments,
chartTypes,
intervals,
lineTypes,
metrics,
operators,
timeWindows,
} from '@openpanel/constants';
export function objectToZodEnums<K extends string>(
obj: Record<K, any>,
): [K, ...K[]] {
const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
return [firstKey!, ...otherKeys];
}
export const mapKeys = objectToZodEnums;
export const zChartEventFilter = z.object({
id: z.string().optional().describe('Unique identifier for the filter'),
name: z.string().describe('The property name to filter on'),
operator: z
.enum(objectToZodEnums(operators))
.describe('The operator to use for the filter'),
value: z
.array(z.string().or(z.number()).or(z.boolean()).or(z.null()))
.describe('The values to filter on'),
});
export const zChartEventSegment = z
.enum(objectToZodEnums(chartSegments))
.default('event')
.describe('Defines how the event data should be segmented or aggregated');
export const zChartEvent = z.object({
id: z
.string()
.optional()
.describe('Unique identifier for the chart event configuration'),
name: z.string().describe('The name of the event as tracked in the system'),
displayName: z
.string()
.optional()
.describe('A user-friendly name for display purposes'),
property: z
.string()
.optional()
.describe(
'Optional property of the event used for specific segment calculations (e.g., value for property_sum/average)',
),
segment: zChartEventSegment,
filters: z
.array(zChartEventFilter)
.default([])
.describe('Filters applied specifically to this event'),
});
export const zChartFormula = z.object({
id: z
.string()
.optional()
.describe('Unique identifier for the formula configuration'),
type: z.literal('formula'),
formula: z.string().describe('The formula expression (e.g., A+B, A/B)'),
displayName: z
.string()
.optional()
.describe('A user-friendly name for display purposes'),
});
// Event with type field for discriminated union
export const zChartEventWithType = zChartEvent.extend({
type: z.literal('event'),
});
export const zChartEventItem = z.discriminatedUnion('type', [
zChartEventWithType,
zChartFormula,
]);
export const zChartBreakdown = z.object({
id: z.string().optional(),
name: z.string(),
});
// Support both old format (array of events without type) and new format (array of event/formula items)
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
export const zChartSeries = z.preprocess((val) => {
if (!val) return val;
let processedVal = val;
// If the input is an object with numeric keys, convert it to an array
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
const keys = Object.keys(val).sort(
(a, b) => Number.parseInt(a) - Number.parseInt(b),
);
processedVal = keys.map((key) => (val as any)[key]);
}
if (!Array.isArray(processedVal)) return processedVal;
return processedVal.map((item: any) => {
// If item already has type field, return as-is
if (item && typeof item === 'object' && 'type' in item) {
return item;
}
// Otherwise, add type: 'event' for backward compatibility
if (item && typeof item === 'object' && 'name' in item) {
return { ...item, type: 'event' };
}
return item;
});
}, z
.array(zChartEventItem)
.describe(
'Array of series (events or formulas) to be tracked and displayed in the chart',
));
// Keep zChartEvents as an alias for backward compatibility during migration
export const zChartEvents = zChartSeries;
export const zChartBreakdowns = z.array(zChartBreakdown);
export const zChartType = z.enum(objectToZodEnums(chartTypes));
export const zLineType = z.enum(objectToZodEnums(lineTypes));
export const zTimeInterval = z.enum(objectToZodEnums(intervals));
export const zMetric = z.enum(objectToZodEnums(metrics));
export const zRange = z.enum(objectToZodEnums(timeWindows));
export const zCriteria = z.enum(['on_or_after', 'on']);
export const zChartInputBase = z.object({
chartType: zChartType
.default('linear')
.describe('What type of chart should be displayed'),
interval: zTimeInterval
.default('day')
.describe(
'The time interval for data aggregation (e.g., day, week, month)',
),
series: zChartSeries.describe(
'Array of series (events or formulas) to be tracked and displayed in the chart',
),
breakdowns: zChartBreakdowns
.default([])
.describe('Array of dimensions to break down the data by'),
range: zRange
.default('30d')
.describe('The time range for which data should be displayed'),
previous: z
.boolean()
.default(false)
.describe('Whether to show data from the previous period for comparison'),
formula: z
.string()
.optional()
.describe('Custom formula for calculating derived metrics'),
metric: zMetric
.default('sum')
.describe(
'The aggregation method for the metric (e.g., sum, count, average)',
),
projectId: z.string().describe('The ID of the project this chart belongs to'),
startDate: z
.string()
.nullish()
.describe(
'Custom start date for the data range (overrides range if provided)',
),
endDate: z
.string()
.nullish()
.describe(
'Custom end date for the data range (overrides range if provided)',
),
limit: z
.number()
.optional()
.describe('Limit how many series should be returned'),
offset: z
.number()
.optional()
.describe('Skip how many series should be returned'),
criteria: zCriteria
.optional()
.describe('Filtering criteria for retention chart (e.g., on_or_after, on)'),
funnelGroup: z
.string()
.optional()
.describe(
'Group identifier for funnel analysis, e.g. "profile_id" or "session_id"',
),
funnelWindow: z
.number()
.optional()
.describe('Time window in hours for funnel analysis'),
});
export const zChartInput = z.preprocess((val) => {
if (val && typeof val === 'object' && 'events' in val && !('series' in val)) {
// Migrate old 'events' field to 'series'
return { ...val, series: val.events };
}
return val;
}, zChartInputBase);
export const zReportInput = zChartInputBase.extend({
name: z.string().describe('The user-defined name for the report'),
lineType: zLineType.describe('The visual style of the line in the chart'),
unit: z
.string()
.optional()
.describe(
"Optional unit of measurement for the chart's Y-axis (e.g., $, %, users)",
),
});
export const zChartInputAI = zReportInput
.omit({
startDate: true,
endDate: true,
lineType: true,
unit: true,
})
.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
});
export const zInviteUser = z.object({
email: z.string().email(),
organizationId: z.string(),
role: z.enum(['org:admin', 'org:member']),
access: z.array(z.string()),
});
export const zShareOverview = z.object({
organizationId: z.string(),
projectId: z.string(),
password: z.string().nullable(),
public: z.boolean(),
});
export const zCreateReference = z.object({
title: z.string(),
description: z.string().nullish(),
projectId: z.string(),
datetime: z.string(),
});
export const zOnboardingProject = z
.object({
organization: z.string().optional(),
organizationId: z.string().optional(),
project: z.string().min(3),
domain: z.string().url().or(z.literal('').or(z.null())),
cors: z.array(z.string()).default([]),
website: z.boolean(),
app: z.boolean(),
backend: z.boolean(),
timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (!data.organization && !data.organizationId) {
ctx.addIssue({
code: 'custom',
message: 'Organization is required',
path: ['organization'],
});
ctx.addIssue({
code: 'custom',
message: 'Organization is required',
path: ['organizationId'],
});
}
if (data.website && !data.domain) {
ctx.addIssue({
code: 'custom',
message: 'Domain is required for website tracking',
path: ['domain'],
});
}
if (
data.website === false &&
data.app === false &&
data.backend === false
) {
for (const key of ['app', 'backend', 'website']) {
ctx.addIssue({
code: 'custom',
message: 'At least one type must be selected',
path: [key],
});
}
}
});
export const zSlackAuthResponse = z.object({
ok: z.literal(true),
app_id: z.string(),
authed_user: z.object({
id: z.string(),
}),
scope: z.string(),
token_type: z.literal('bot'),
access_token: z.string(),
bot_user_id: z.string(),
team: z.object({
id: z.string(),
name: z.string(),
}),
incoming_webhook: z.object({
channel: z.string(),
channel_id: z.string(),
configuration_url: z.string().url(),
url: z.string().url(),
}),
});
export const zSlackConfig = z
.object({
type: z.literal('slack'),
})
.merge(zSlackAuthResponse);
export type ISlackConfig = z.infer<typeof zSlackConfig>;
export const zWebhookConfig = z.object({
type: z.literal('webhook'),
url: z.string().url(),
headers: z.record(z.string()),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;
export const zDiscordConfig = z.object({
type: z.literal('discord'),
url: z.string().url(),
});
export type IDiscordConfig = z.infer<typeof zDiscordConfig>;
export const zAppConfig = z.object({
type: z.literal('app'),
});
export type IAppConfig = z.infer<typeof zAppConfig>;
export const zEmailConfig = z.object({
type: z.literal('email'),
});
export type IEmailConfig = z.infer<typeof zEmailConfig>;
export type IIntegrationConfig =
| ISlackConfig
| IDiscordConfig
| IWebhookConfig
| IAppConfig
| IEmailConfig;
const zCreateIntegration = z.object({
id: z.string().optional(),
name: z.string().min(1),
organizationId: z.string().min(1),
});
export const zCreateSlackIntegration = zCreateIntegration;
export const zCreateWebhookIntegration = zCreateIntegration.merge(
z.object({
config: zWebhookConfig,
}),
);
export const zCreateDiscordIntegration = zCreateIntegration.merge(
z.object({
config: zDiscordConfig,
}),
);
export const zNotificationRuleEventConfig = z.object({
type: z.literal('events'),
events: z.array(zChartEvent),
});
export type INotificationRuleEventConfig = z.infer<
typeof zNotificationRuleEventConfig
>;
export const zNotificationRuleFunnelConfig = z.object({
type: z.literal('funnel'),
events: z.array(zChartEvent).min(1),
});
export type INotificationRuleFunnelConfig = z.infer<
typeof zNotificationRuleFunnelConfig
>;
export const zNotificationRuleConfig = z.discriminatedUnion('type', [
zNotificationRuleEventConfig,
zNotificationRuleFunnelConfig,
]);
export type INotificationRuleConfig = z.infer<typeof zNotificationRuleConfig>;
export const zCreateNotificationRule = z.object({
id: z.string().optional(),
name: z.string().min(1),
template: z.string().optional(),
config: zNotificationRuleConfig,
integrations: z.array(z.string()),
sendToApp: z.boolean(),
sendToEmail: z.boolean(),
projectId: z.string(),
});
export const zProjectFilterIp = z.object({
type: z.literal('ip'),
ip: z.string(),
});
export type IProjectFilterIp = z.infer<typeof zProjectFilterIp>;
export const zProjectFilterProfileId = z.object({
type: z.literal('profile_id'),
profileId: z.string(),
});
export type IProjectFilterProfileId = z.infer<typeof zProjectFilterProfileId>;
export const zProjectFilters = z.discriminatedUnion('type', [
zProjectFilterIp,
zProjectFilterProfileId,
]);
export type IProjectFilters = z.infer<typeof zProjectFilters>;
export const zProject = z.object({
id: z.string(),
name: z.string().min(1),
filters: z.array(zProjectFilters).default([]),
domain: z.string().url().or(z.literal('').or(z.null())),
cors: z.array(z.string()).default([]),
crossDomain: z.boolean().default(false),
allowUnsafeRevenueTracking: z.boolean().default(false),
});
export type IProjectEdit = z.infer<typeof zProject>;
export const zPassword = z.string().min(8);
export const zSignInEmail = z.object({
email: z.string().email().min(1),
password: zPassword,
});
export type ISignInEmail = z.infer<typeof zSignInEmail>;
export const zSignUpEmail = z
.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
password: zPassword,
confirmPassword: zPassword,
inviteId: z.string().nullish(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export type ISignUpEmail = z.infer<typeof zSignUpEmail>;
export const zResetPassword = z.object({
token: z.string(),
password: z.string().min(8),
});
export type IResetPassword = z.infer<typeof zResetPassword>;
export const zRequestResetPassword = z.object({
email: z.string().email(),
});
export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
export const zSignInShare = z.object({
password: z.string().min(1),
shareId: z.string().min(1),
});
export type ISignInShare = z.infer<typeof zSignInShare>;
export const zCheckout = z.object({
productPriceId: z.string(),
organizationId: z.string(),
projectId: z.string().nullish(),
productId: z.string(),
});
export type ICheckout = z.infer<typeof zCheckout>;
export const zEditOrganization = z.object({
id: z.string().min(2),
name: z.string().min(2),
timezone: z.string().min(1),
});
const zProjectMapper = z.object({
from: z.string().min(1),
to: z.string().min(1),
});
const createFileImportConfig = <T extends string>(provider: T) =>
z.object({
provider: z.literal(provider),
type: z.literal('file'),
fileUrl: z.string().url(),
});
// Import configs
export const zUmamiImportConfig = createFileImportConfig('umami').extend({
projectMapper: z.array(zProjectMapper),
});
export type IUmamiImportConfig = z.infer<typeof zUmamiImportConfig>;
export const zPlausibleImportConfig = createFileImportConfig('plausible');
export type IPlausibleImportConfig = z.infer<typeof zPlausibleImportConfig>;
export const zMixpanelImportConfig = z.object({
provider: z.literal('mixpanel'),
type: z.literal('api'),
serviceAccount: z.string().min(1),
serviceSecret: z.string().min(1),
projectId: z.string().min(1),
from: z.string().min(1),
to: z.string().min(1),
mapScreenViewProperty: z.string().optional(),
});
export type IMixpanelImportConfig = z.infer<typeof zMixpanelImportConfig>;
export type IImportConfig =
| IUmamiImportConfig
| IPlausibleImportConfig
| IMixpanelImportConfig;
export const zCreateImport = z.object({
projectId: z.string().min(1),
provider: z.enum(['umami', 'plausible', 'mixpanel']),
config: z.union([
zUmamiImportConfig,
zPlausibleImportConfig,
zMixpanelImportConfig,
]),
});
export type ICreateImport = z.infer<typeof zCreateImport>;