ts
This commit is contained in:
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { shortId } from '@openpanel/common';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { db } from '../index';
|
||||
import { printBoxMessage } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
printBoxMessage('🔄 Migrating Events to Series Format', []);
|
||||
|
||||
// Get all reports
|
||||
const reports = await db.report.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
events: true,
|
||||
formula: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
let migratedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let formulaAddedCount = 0;
|
||||
|
||||
for (const report of reports) {
|
||||
const events = report.events as unknown as Array<
|
||||
Partial<IChartEventItem> | Partial<IChartEvent>
|
||||
>;
|
||||
const oldFormula = report.formula;
|
||||
|
||||
// Check if any event is missing the 'type' field (old format)
|
||||
const needsEventMigration =
|
||||
Array.isArray(events) &&
|
||||
events.length > 0 &&
|
||||
events.some(
|
||||
(event) => !event || typeof event !== 'object' || !('type' in event),
|
||||
);
|
||||
|
||||
// Check if formula exists and isn't already in the series
|
||||
const hasFormulaInSeries =
|
||||
Array.isArray(events) &&
|
||||
events.some(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'type' in item &&
|
||||
item.type === 'formula',
|
||||
);
|
||||
|
||||
const needsFormulaMigration = !!oldFormula && !hasFormulaInSeries;
|
||||
|
||||
// Skip if no migration needed
|
||||
if (!needsEventMigration && !needsFormulaMigration) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform events to new format: add type: 'event' to each event
|
||||
const migratedSeries: IChartEventItem[] = Array.isArray(events)
|
||||
? events.map((event) => {
|
||||
if (event && typeof event === 'object' && 'type' in event) {
|
||||
return event as IChartEventItem;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
type: 'event',
|
||||
} as IChartEventItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
// Add formula to series if it exists and isn't already there
|
||||
if (needsFormulaMigration && oldFormula) {
|
||||
const formulaItem: IChartFormula = {
|
||||
type: 'formula',
|
||||
formula: oldFormula,
|
||||
id: shortId(),
|
||||
};
|
||||
migratedSeries.push(formulaItem);
|
||||
formulaAddedCount++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`,
|
||||
);
|
||||
// Update the report with migrated series
|
||||
await db.report.update({
|
||||
where: { id: report.id },
|
||||
data: {
|
||||
events: migratedSeries,
|
||||
},
|
||||
});
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
|
||||
printBoxMessage('✅ Migration Complete', [
|
||||
`Migrated: ${migratedCount} reports`,
|
||||
`Formulas added: ${formulaAddedCount} reports`,
|
||||
`Skipped: ${skippedCount} reports (already in new format or empty)`,
|
||||
]);
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import { slug } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
} from '@openpanel/validation';
|
||||
import type { IChartEventItem } from '@openpanel/validation';
|
||||
import { getSettingsForProject } from '../services/organization.service';
|
||||
import type { ConcreteSeries, Plan } from './types';
|
||||
import type { NormalizedInput } from './normalize';
|
||||
import type { ConcreteSeries, Plan } from './types';
|
||||
|
||||
/**
|
||||
* Create an execution plan from normalized input
|
||||
* This sets up ConcreteSeries placeholders - actual breakdown expansion happens during fetch
|
||||
*/
|
||||
export async function plan(
|
||||
normalized: NormalizedInput,
|
||||
): Promise<Plan> {
|
||||
export async function plan(normalized: NormalizedInput): Promise<Plan> {
|
||||
const { timezone } = await getSettingsForProject(normalized.projectId);
|
||||
|
||||
const concreteSeries: ConcreteSeries[] = [];
|
||||
@@ -24,7 +18,7 @@ export async function plan(
|
||||
normalized.series.forEach((definition, index) => {
|
||||
if (definition.type === 'event') {
|
||||
const event = definition as IChartEventItem & { type: 'event' };
|
||||
|
||||
|
||||
// For events, create a placeholder
|
||||
// If breakdowns exist, fetch will return multiple series (one per breakdown value)
|
||||
// If no breakdowns, fetch will return one series
|
||||
@@ -54,6 +48,3 @@ export async function plan(
|
||||
timezone,
|
||||
};
|
||||
}
|
||||
|
||||
export type NormalizedInput = Awaited<ReturnType<typeof import('./normalize').normalize>>;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getEventFiltersWhereClause,
|
||||
getSelectPropertyKey,
|
||||
} from './chart.service';
|
||||
import { onlyReportEvents } from './reports.service';
|
||||
|
||||
export class ConversionService {
|
||||
constructor(private client: typeof ch) {}
|
||||
@@ -18,7 +19,6 @@ export class ConversionService {
|
||||
funnelGroup,
|
||||
funnelWindow = 24,
|
||||
series,
|
||||
events, // Backward compatibility - use series if available
|
||||
breakdowns = [],
|
||||
interval,
|
||||
timezone,
|
||||
@@ -31,12 +31,9 @@ export class ConversionService {
|
||||
);
|
||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const eventSeries = (series ?? events ?? []).filter(
|
||||
(item): item is IChartEvent => item.type === 'event',
|
||||
) as IChartEvent[];
|
||||
const events = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length !== 2) {
|
||||
if (events.length !== 2) {
|
||||
throw new Error('events must be an array of two events');
|
||||
}
|
||||
|
||||
@@ -44,8 +41,8 @@ export class ConversionService {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
const eventA = eventSeries[0]!;
|
||||
const eventB = eventSeries[1]!;
|
||||
const eventA = events[0]!;
|
||||
const eventB = events[1]!;
|
||||
const whereA = Object.values(
|
||||
getEventFiltersWhereClause(eventA.filters),
|
||||
).join(' AND ');
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getEventFiltersWhereClause,
|
||||
getSelectPropertyKey,
|
||||
} from './chart.service';
|
||||
import { onlyReportEvents } from './reports.service';
|
||||
|
||||
export class FunnelService {
|
||||
constructor(private client: typeof ch) {}
|
||||
@@ -179,7 +180,6 @@ export class FunnelService {
|
||||
startDate,
|
||||
endDate,
|
||||
series,
|
||||
events, // Backward compatibility - use series if available
|
||||
funnelWindow = 24,
|
||||
funnelGroup,
|
||||
breakdowns = [],
|
||||
@@ -189,12 +189,7 @@ export class FunnelService {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const rawSeries = (series ?? events ?? []) as IChartEventItem[];
|
||||
const eventSeries = rawSeries.filter(
|
||||
(item): item is IChartEventItem & { type: 'event' } =>
|
||||
item.type === 'event',
|
||||
) as IChartEvent[];
|
||||
const eventSeries = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length === 0) {
|
||||
throw new Error('events are required');
|
||||
|
||||
@@ -5,21 +5,25 @@ import {
|
||||
} from '@openpanel/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventFilter,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
ICriteria,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import { db } from '../prisma-client';
|
||||
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export type IServiceReport = Awaited<ReturnType<typeof getReportById>>;
|
||||
|
||||
export const onlyReportEvents = (
|
||||
series: NonNullable<IServiceReport>['series'],
|
||||
) => {
|
||||
return series.filter((item) => item.type === 'event');
|
||||
};
|
||||
|
||||
export function transformFilter(
|
||||
filter: Partial<IChartEventFilter>,
|
||||
index: number,
|
||||
@@ -34,72 +38,39 @@ export function transformFilter(
|
||||
}
|
||||
|
||||
export function transformReportEventItem(
|
||||
item: Partial<IChartEventItem> | Partial<IChartEvent>,
|
||||
item: IChartEventItem,
|
||||
index: number,
|
||||
): IChartEventItem {
|
||||
// If item already has type field, it's the new format
|
||||
if (item && typeof item === 'object' && 'type' in item) {
|
||||
if (item.type === 'formula') {
|
||||
// Transform formula
|
||||
const formula = item as Partial<IChartFormula>;
|
||||
return {
|
||||
type: 'formula',
|
||||
id: formula.id ?? alphabetIds[index]!,
|
||||
formula: formula.formula || '',
|
||||
displayName: formula.displayName,
|
||||
};
|
||||
}
|
||||
// Transform event with type field
|
||||
const event = item as Partial<IChartEvent>;
|
||||
if (item.type === 'formula') {
|
||||
// Transform formula
|
||||
return {
|
||||
type: 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: (event.filters ?? []).map(transformFilter),
|
||||
id: event.id ?? alphabetIds[index]!,
|
||||
name: event.name || 'unknown_event',
|
||||
displayName: event.displayName,
|
||||
property: event.property,
|
||||
type: 'formula',
|
||||
id: item.id ?? alphabetIds[index]!,
|
||||
formula: item.formula || '',
|
||||
displayName: item.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
// Old format without type field - assume it's an event
|
||||
const event = item as Partial<IChartEvent>;
|
||||
// Transform event with type field
|
||||
return {
|
||||
type: 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: (event.filters ?? []).map(transformFilter),
|
||||
id: event.id ?? alphabetIds[index]!,
|
||||
name: event.name || 'unknown_event',
|
||||
displayName: event.displayName,
|
||||
property: event.property,
|
||||
segment: item.segment ?? 'event',
|
||||
filters: (item.filters ?? []).map(transformFilter),
|
||||
id: item.id ?? alphabetIds[index]!,
|
||||
name: item.name || 'unknown_event',
|
||||
displayName: item.displayName,
|
||||
property: item.property,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the old function for backward compatibility, but it now uses the new transformer
|
||||
export function transformReportEvent(
|
||||
event: Partial<IChartEvent>,
|
||||
index: number,
|
||||
): IChartEvent {
|
||||
const transformed = transformReportEventItem(event, index);
|
||||
if (transformed.type === 'event') {
|
||||
return transformed;
|
||||
}
|
||||
// This shouldn't happen for old code, but handle it gracefully
|
||||
throw new Error('transformReportEvent called on a formula');
|
||||
}
|
||||
|
||||
export function transformReport(
|
||||
report: DbReport & { layout?: ReportLayout | null },
|
||||
): IChartProps & { id: string; layout?: ReportLayout | null } {
|
||||
// Events can be either old format (IChartEvent[]) or new format (IChartEventItem[])
|
||||
const eventsData = report.events as unknown as Array<
|
||||
Partial<IChartEventItem> | Partial<IChartEvent>
|
||||
>;
|
||||
|
||||
return {
|
||||
id: report.id,
|
||||
projectId: report.projectId,
|
||||
series: eventsData.map(transformReportEventItem),
|
||||
series:
|
||||
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||
chartType: report.chartType,
|
||||
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getProfilesCached,
|
||||
getSelectPropertyKey,
|
||||
getSettingsForProject,
|
||||
onlyReportEvents,
|
||||
} from '@openpanel/db';
|
||||
import {
|
||||
type IChartEvent,
|
||||
@@ -611,9 +612,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// });
|
||||
|
||||
// Get unique profile IDs
|
||||
console.log('profileIdsQuery', getSql());
|
||||
const profileIds = await chQuery<{ profile_id: string }>(getSql());
|
||||
console.log('profileIds', profileIds.length);
|
||||
if (profileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -663,10 +662,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
|
||||
const targetLevel = stepIndex + 1;
|
||||
|
||||
const eventSeries = series.filter(
|
||||
(item): item is typeof item & { type: 'event' } =>
|
||||
item.type === 'event',
|
||||
);
|
||||
const eventSeries = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length === 0) {
|
||||
throw new Error('At least one event series is required');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type UnionOmit<T, K extends keyof any> = T extends any
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
|
||||
import type {
|
||||
zChartBreakdown,
|
||||
zChartEvent,
|
||||
|
||||
Reference in New Issue
Block a user