feature(dashboard,api): add timezone support

* feat(dashboard): add support for today, yesterday etc (timezones)

* fix(db): escape js dates

* fix(dashboard): ensure we support default timezone

* final fixes

* remove complete series and add sql with fill instead
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-05-23 11:26:44 +02:00
committed by GitHub
parent 46bfeee131
commit 680727355b
48 changed files with 1817 additions and 758 deletions

View File

@@ -1,45 +1,29 @@
import {
differenceInMilliseconds,
endOfMonth,
endOfYear,
formatISO,
startOfDay,
startOfMonth,
startOfYear,
subDays,
subMilliseconds,
subMinutes,
subMonths,
subYears,
} from 'date-fns';
import * as mathjs from 'mathjs';
import { last, pluck, repeat, reverse, uniq } from 'ramda';
import { last, pluck, reverse, uniq } from 'ramda';
import { escape } from 'sqlstring';
import type { ISerieDataItem } from '@openpanel/common';
import {
DateTime,
average,
completeSerie,
getPreviousMetric,
groupByLabels,
max,
min,
round,
slug,
sum,
} from '@openpanel/common';
import type { ISerieDataItem } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import {
TABLE_NAMES,
chQuery,
createSqlBuilder,
db,
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
getOrganizationByProjectId,
getOrganizationByProjectIdCached,
getOrganizationSubscriptionChartEndDate,
getProfiles,
getSettingsForProject,
} from '@openpanel/db';
import type {
FinalChart,
@@ -48,9 +32,7 @@ import type {
IChartInputWithDates,
IChartRange,
IGetChartDataInput,
IInterval,
} from '@openpanel/validation';
import { TRPCNotFoundError } from '../errors';
function getEventLegend(event: IChartEvent) {
return event.displayName || event.name;
@@ -134,115 +116,190 @@ export function withFormula(
];
}
const toDynamicISODateWithTZ = (
date: string,
blueprint: string,
interval: IInterval,
) => {
// If we have a space in the date we know it's a date with time
if (date.includes(' ')) {
// If interval is minutes we need to convert the timezone to what timezone is used (either on client or the server)
// - We use timezone from server if its a predefined range (yearToDate, lastYear, etc.)
// - We use timezone from client if its a custom range
if (interval === 'minute' || interval === 'hour') {
return (
date.replace(' ', 'T') +
// Only append timezone if it's not UTC (Z)
(blueprint.match(/[+-]\d{2}:\d{2}/) ? blueprint.slice(-6) : 'Z')
);
}
// Otherwise we just return without the timezone
// It will be converted to the correct timezone on the client
return date.replace(' ', 'T');
}
return `${date}T00:00:00Z`;
};
export function getDatesFromRange(range: IChartRange) {
export function getDatesFromRange(range: IChartRange, timezone: string) {
if (range === '30min' || range === 'lastHour') {
const minutes = range === '30min' ? 30 : 60;
const startDate = formatISO(subMinutes(new Date(), minutes));
const endDate = formatISO(new Date());
const startDate = DateTime.now()
.minus({ minute: minutes })
.startOf('minute')
.setZone(timezone)
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('minute')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
if (range === 'today') {
// This is last 24 hours instead
// Makes it easier to handle timezones
// const startDate = startOfDay(new Date());
// const endDate = endOfDay(new Date());
const startDate = subDays(new Date(), 1);
const endDate = new Date();
const startDate = DateTime.now()
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: formatISO(startDate),
endDate: formatISO(endDate),
startDate: startDate,
endDate: endDate,
};
}
if (range === 'yesterday') {
const startDate = DateTime.now()
.minus({ day: 1 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.minus({ day: 1 })
.setZone(timezone)
.endOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === '7d') {
const startDate = formatISO(startOfDay(subDays(new Date(), 7)));
const endDate = formatISO(new Date());
const startDate = DateTime.now()
.minus({ day: 7 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
if (range === '6m') {
const startDate = DateTime.now()
.minus({ month: 6 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === '12m') {
const startDate = DateTime.now()
.minus({ month: 12 })
.setZone(timezone)
.startOf('month')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('month')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'monthToDate') {
const startDate = formatISO(startOfMonth(new Date()));
const endDate = formatISO(new Date());
const startDate = DateTime.now()
.setZone(timezone)
.startOf('month')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
if (range === 'lastMonth') {
const month = subMonths(new Date(), 1);
const startDate = formatISO(startOfMonth(month));
const endDate = formatISO(endOfMonth(month));
const month = DateTime.now()
.minus({ month: 1 })
.setZone(timezone)
.startOf('month');
const startDate = month.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = month
.endOf('month')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
if (range === 'yearToDate') {
const startDate = formatISO(startOfYear(new Date()));
const endDate = formatISO(new Date());
const startDate = DateTime.now()
.setZone(timezone)
.startOf('year')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
if (range === 'lastYear') {
const year = subYears(new Date(), 1);
const startDate = formatISO(startOfYear(year));
const endDate = formatISO(endOfYear(year));
const year = DateTime.now().minus({ year: 1 }).setZone(timezone);
const startDate = year.startOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
// range === '30d'
const startDate = formatISO(startOfDay(subDays(new Date(), 30)));
const endDate = formatISO(new Date());
const startDate = DateTime.now()
.minus({ day: 30 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate,
endDate,
startDate: startDate,
endDate: endDate,
};
}
@@ -268,12 +325,15 @@ function fillFunnel(funnel: { level: number; count: number }[], steps: number) {
return filled.reverse();
}
export function getChartStartEndDate({
startDate,
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>) {
const ranges = getDatesFromRange(range);
export function getChartStartEndDate(
{
startDate,
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
timezone: string,
) {
const ranges = getDatesFromRange(range, timezone);
if (startDate && endDate) {
return { startDate: startDate, endDate: endDate };
@@ -293,10 +353,25 @@ export function getChartPrevStartEndDate({
startDate: string;
endDate: string;
}) {
const diff = differenceInMilliseconds(new Date(endDate), new Date(startDate));
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff(
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss'),
);
// this will make sure our start and end date's are correct
// otherwise if a day ends with 23:59:59.999 and starts with 00:00:00.000
// the diff will be 23:59:59.999 and that will make the start date wrong
// so we add 1 millisecond to the diff
if ((diff.milliseconds / 1000) % 2 !== 0) {
diff = diff.plus({ millisecond: 1 });
}
return {
startDate: formatISO(subMilliseconds(new Date(startDate), diff + 1000)),
endDate: formatISO(subMilliseconds(new Date(endDate), diff + 1000)),
startDate: DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss')
.minus({ millisecond: diff.milliseconds })
.toFormat('yyyy-MM-dd HH:mm:ss'),
endDate: DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss')
.minus({ millisecond: diff.milliseconds })
.toFormat('yyyy-MM-dd HH:mm:ss'),
};
}
@@ -386,118 +461,60 @@ export async function getFunnelData({
};
}
export async function getFunnelStep({
projectId,
startDate,
endDate,
step,
...payload
}: IChartInput & {
step: number;
}) {
throw new Error('not implemented');
// if (!startDate || !endDate) {
// throw new Error('startDate and endDate are required');
// }
// if (payload.events.length === 0) {
// throw new Error('no events selected');
// }
// const funnels = payload.events.map((event) => {
// const { sb, getWhere } = createSqlBuilder();
// sb.where = getEventFiltersWhereClause(event.filters);
// sb.where.name = `name = ${escape(event.name)}`;
// return getWhere().replace('WHERE ', '');
// });
// const innerSql = `SELECT
// session_id,
// windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
// FROM ${TABLE_NAMES.events}
// WHERE
// project_id = ${escape(projectId)} AND
// created_at >= '${formatClickhouseDate(startDate)}' AND
// created_at <= '${formatClickhouseDate(endDate)}' AND
// name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
// GROUP BY session_id`;
// const profileIdsQuery = `WITH sessions AS (${innerSql})
// SELECT
// DISTINCT e.profile_id as id
// FROM sessions s
// JOIN ${TABLE_NAMES.events} e ON s.session_id = e.session_id
// WHERE
// s.level = ${step} AND
// e.project_id = ${escape(projectId)} AND
// e.created_at >= '${formatClickhouseDate(startDate)}' AND
// e.created_at <= '${formatClickhouseDate(endDate)}' AND
// name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
// ORDER BY e.created_at DESC
// LIMIT 500
// `;
// const res = await chQuery<{
// id: string;
// }>(profileIdsQuery);
// return getProfiles(
// res.map((r) => r.id),
// projectId,
// );
}
export async function getChartSerie(payload: IGetChartDataInput) {
export async function getChartSerie(
payload: IGetChartDataInput,
timezone: string,
) {
async function getSeries() {
const result = await chQuery<ISerieDataItem>(getChartSql(payload));
const result = await chQuery<ISerieDataItem>(
getChartSql({ ...payload, timezone }),
{
session_timezone: timezone,
},
);
if (result.length === 0 && payload.breakdowns.length > 0) {
return await chQuery<ISerieDataItem>(
getChartSql({
...payload,
breakdowns: [],
timezone,
}),
{
session_timezone: timezone,
},
);
}
return result;
}
return getSeries()
.then((data) =>
completeSerie(data, payload.startDate, payload.endDate, payload.interval),
)
.then(groupByLabels)
.then((series) => {
return Object.keys(series).map((key) => {
const firstDataItem = series[key]![0]!;
const isBreakdown =
payload.breakdowns.length && firstDataItem.labels.length;
const serieLabel = isBreakdown
? firstDataItem.labels
: [getEventLegend(payload.event)];
return series.map((serie) => {
return {
name: serieLabel,
...serie,
event: payload.event,
data: series[key]!.map((item) => ({
...item,
date: toDynamicISODateWithTZ(
item.date,
payload.startDate,
payload.interval,
),
})),
};
});
});
}
export type IGetChartSerie = Awaited<ReturnType<typeof getChartSeries>>[number];
export async function getChartSeries(input: IChartInputWithDates) {
export async function getChartSeries(
input: IChartInputWithDates,
timezone: string,
) {
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartSerie({
...input,
event,
}),
getChartSerie(
{
...input,
event,
},
timezone,
),
),
)
).flat();
@@ -510,7 +527,8 @@ export async function getChartSeries(input: IChartInputWithDates) {
}
export async function getChart(input: IChartInput) {
const currentPeriod = getChartStartEndDate(input);
const { timezone } = await getSettingsForProject(input.projectId);
const currentPeriod = getChartStartEndDate(input, timezone);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const endDate = await getOrganizationSubscriptionChartEndDate(
@@ -522,14 +540,17 @@ export async function getChart(input: IChartInput) {
currentPeriod.endDate = endDate;
}
const promises = [getChartSeries({ ...input, ...currentPeriod })];
const promises = [getChartSeries({ ...input, ...currentPeriod }, timezone)];
if (input.previous) {
promises.push(
getChartSeries({
...input,
...previousPeriod,
}),
getChartSeries(
{
...input,
...previousPeriod,
},
timezone,
),
);
}

View File

@@ -13,6 +13,7 @@ import {
db,
funnelService,
getSelectPropertyKey,
getSettingsForProject,
toDate,
} from '@openpanel/db';
import {
@@ -80,7 +81,7 @@ export const chartRouter = createTRPCRouter({
}),
)
.query(async ({ input: { projectId, event } }) => {
const profiles = await clix(ch)
const profiles = await clix(ch, 'UTC')
.select<Pick<IServiceProfile, 'properties'>>(['properties'])
.from(TABLE_NAMES.profiles)
.where('project_id', '=', projectId)
@@ -214,7 +215,8 @@ export const chartRouter = createTRPCRouter({
}),
funnel: protectedProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
const { timezone } = await getSettingsForProject(input.projectId);
const currentPeriod = getChartStartEndDate(input, timezone);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
@@ -231,7 +233,8 @@ export const chartRouter = createTRPCRouter({
}),
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
const { timezone } = await getSettingsForProject(input.projectId);
const currentPeriod = getChartStartEndDate(input, timezone);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
@@ -254,7 +257,7 @@ export const chartRouter = createTRPCRouter({
}),
chart: publicProcedure
.use(cacher)
// .use(cacher)
.input(zChartInput)
.query(async ({ input, ctx }) => {
if (ctx.session.userId) {
@@ -301,8 +304,9 @@ export const chartRouter = createTRPCRouter({
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { projectId, firstEvent, secondEvent } = input;
const dates = getChartStartEndDate(input);
const dates = getChartStartEndDate(input, timezone);
const diffInterval = {
minute: () => differenceInDays(dates.endDate, dates.startDate),
hour: () => differenceInDays(dates.endDate, dates.startDate),

View File

@@ -12,6 +12,7 @@ import {
formatClickhouseDate,
getEventList,
getEvents,
getSettingsForProject,
overviewService,
sessionService,
} from '@openpanel/db';
@@ -275,7 +276,8 @@ export const eventRouter = createTRPCRouter({
}),
)
.query(async ({ input }) => {
const { startDate, endDate } = getChartStartEndDate(input);
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
if (input.search) {
input.filters.push({
id: 'path',
@@ -292,6 +294,7 @@ export const eventRouter = createTRPCRouter({
interval: input.interval,
cursor: input.cursor || 1,
limit: input.take,
timezone,
});
}),

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import type { z } from 'zod';
import { stripTrailingSlash } from '@openpanel/common';
import { db, getId, getOrganizationBySlug, getUserById } from '@openpanel/db';
import { db, getId, getOrganizationById, getUserById } from '@openpanel/db';
import type { IServiceUser, ProjectType } from '@openpanel/db';
import { zOnboardingProject } from '@openpanel/validation';
@@ -16,7 +16,7 @@ async function createOrGetOrganization(
user: IServiceUser,
) {
if (input.organizationId) {
return await getOrganizationBySlug(input.organizationId);
return await getOrganizationById(input.organizationId);
}
const TRIAL_DURATION_IN_DAYS = 30;
@@ -29,6 +29,7 @@ async function createOrGetOrganization(
createdByUserId: user.id,
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
},
});

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
import { connectUserToOrganization, db } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
import { zEditOrganization, zInviteUser } from '@openpanel/validation';
import { generateSecureId } from '@openpanel/common/server/id';
import { sendEmail } from '@openpanel/email';
@@ -12,12 +12,7 @@ import { createTRPCRouter, protectedProcedure } from '../trpc';
export const organizationRouter = createTRPCRouter({
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
}),
)
.input(zEditOrganization)
.mutation(async ({ input, ctx }) => {
const access = await getOrganizationAccess({
userId: ctx.session.userId,
@@ -34,6 +29,7 @@ export const organizationRouter = createTRPCRouter({
},
data: {
name: input.name,
timezone: input.timezone,
},
});
}),

View File

@@ -1,13 +1,13 @@
import {
getOrganizationByProjectIdCached,
getOrganizationSubscriptionChartEndDate,
getSettingsForProject,
overviewService,
zGetMetricsInput,
zGetTopGenericInput,
zGetTopPagesInput,
} from '@openpanel/db';
import { type IChartRange, zRange } from '@openpanel/validation';
import { TRPCError } from '@trpc/server';
import { format } from 'date-fns';
import { z } from 'zod';
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
import {
@@ -34,8 +34,8 @@ function getCurrentAndPrevious<
range: IChartRange;
projectId: string;
},
>(input: T, fetchPrevious = false) {
const current = getChartStartEndDate(input);
>(input: T, fetchPrevious: boolean, timezone: string) {
const current = getChartStartEndDate(input, timezone);
const previous = getChartPrevStartEndDate(current);
return async <R>(
@@ -88,9 +88,11 @@ export const overviewRouter = createTRPCRouter({
)
.use(cacher)
.query(async ({ ctx, input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { current, previous } = await getCurrentAndPrevious(
input,
{ ...input, timezone },
true,
timezone,
)(overviewService.getMetrics.bind(overviewService));
return {
metrics: {
@@ -107,6 +109,7 @@ export const overviewRouter = createTRPCRouter({
const prev = previous?.series[index];
return {
...item,
date: format(item.date, 'yyyy-MM-dd HH:mm:ss'),
prev_bounce_rate: prev?.bounce_rate,
prev_unique_visitors: prev?.unique_visitors,
prev_total_screen_views: prev?.total_screen_views,
@@ -129,12 +132,14 @@ export const overviewRouter = createTRPCRouter({
)
.use(cacher)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious(
input,
{ ...input },
false,
timezone,
)(async (input) => {
if (input.mode === 'page') {
return overviewService.getTopPages(input);
return overviewService.getTopPages({ ...input, timezone });
}
if (input.mode === 'bot') {
@@ -144,6 +149,7 @@ export const overviewRouter = createTRPCRouter({
return overviewService.getTopEntryExit({
...input,
mode: input.mode,
timezone,
});
});
@@ -160,9 +166,11 @@ export const overviewRouter = createTRPCRouter({
)
.use(cacher)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious(
input,
{ ...input, timezone },
false,
timezone,
)(overviewService.getTopGeneric.bind(overviewService));
return current;

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { db, getReferences } from '@openpanel/db';
import { db, getReferences, getSettingsForProject } from '@openpanel/db';
import { zCreateReference, zRange } from '@openpanel/validation';
import { getProjectAccess } from '../access';
@@ -56,8 +56,9 @@ export const referenceRouter = createTRPCRouter({
range: zRange,
}),
)
.query(({ input: { projectId, ...input } }) => {
const { startDate, endDate } = getChartStartEndDate(input);
.query(async ({ input: { projectId, ...input } }) => {
const { timezone } = await getSettingsForProject(projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
return getReferences({
where: {
projectId,

View File

@@ -1,7 +1,7 @@
import {
db,
getOrganizationBillingEventsCountSerieCached,
getOrganizationBySlug,
getOrganizationById,
} from '@openpanel/db';
import {
cancelSubscription,
@@ -24,7 +24,7 @@ export const subscriptionRouter = createTRPCRouter({
getCurrent: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
const organization = await getOrganizationById(input.organizationId);
if (!organization.subscriptionProductId) {
return null;
@@ -150,7 +150,7 @@ export const subscriptionRouter = createTRPCRouter({
cancelSubscription: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
const organization = await getOrganizationById(input.organizationId);
if (!organization.subscriptionId) {
throw TRPCBadRequestError('Organization has no subscription');
}
@@ -163,7 +163,7 @@ export const subscriptionRouter = createTRPCRouter({
portal: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
const organization = await getOrganizationById(input.organizationId);
if (!organization.subscriptionCustomerId) {
throw TRPCBadRequestError('Organization has no subscription');
}