Compare commits

..

12 Commits

Author SHA1 Message Date
TheodorTomas
5b9372fb69 fix: changing dashboard api routes to be server actions 2026-02-19 20:05:01 +07:00
TheodorTomas
6080fb9c63 feat: adding CRUD operations for dashboards 2026-02-19 20:01:09 +07:00
TheodorTomas
d01dc80712 fix: changing charts api routes to server actions 2026-02-19 19:33:40 +07:00
TheodorTomas
d32437b4a6 feat: adding CRUD operations for charts 2026-02-19 17:51:13 +07:00
Theodór Tómas
f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas
9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang
4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang
1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang
4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang
18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang
881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang
e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
19 changed files with 1179 additions and 200 deletions

View File

@@ -229,5 +229,14 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -12,7 +12,9 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};

View File

@@ -0,0 +1,293 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZChartType } from "../types/analysis";
const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: "read" | "readWrite" | "manage"
) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};
const ZCreateChartAction = z.object({
environmentId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig.optional().default({}),
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await prisma.chart.create({
data: {
name: parsedInput.name,
type: parsedInput.type,
projectId,
query: parsedInput.query,
config: parsedInput.config || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
name: z.string().min(1).optional(),
type: ZChartType.optional(),
query: ZChartQuery.optional(),
config: ZChartConfig.optional(),
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
const updatedChart = await prisma.chart.update({
where: { id: parsedInput.chartId },
data: {
...(parsedInput.name !== undefined && { name: parsedInput.name }),
...(parsedInput.type !== undefined && { type: parsedInput.type }),
...(parsedInput.query !== undefined && { query: parsedInput.query }),
...(parsedInput.config !== undefined && { config: parsedInput.config }),
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const sourceChart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!sourceChart) {
throw new Error("Chart not found");
}
const duplicatedChart = await prisma.chart.create({
data: {
name: `${sourceChart.name} (copy)`,
type: sourceChart.type,
projectId,
query: sourceChart.query as object,
config: (sourceChart.config as object) || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
await prisma.chart.delete({
where: { id: parsedInput.chartId },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
});
if (!chart) {
throw new Error("Chart not found");
}
return chart;
}
);
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
createdAt: true,
updatedAt: true,
query: true,
config: true,
widgets: {
select: { dashboardId: true },
},
},
});
}
);

View File

@@ -0,0 +1,300 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZWidgetLayout } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: "read" | "readWrite" | "manage"
) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await prisma.dashboard.create({
data: {
name: parsedInput.name,
description: parsedInput.description,
projectId,
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
});
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
const updatedDashboard = await prisma.dashboard.update({
where: { id: parsedInput.dashboardId },
data: {
...(parsedInput.name !== undefined && { name: parsedInput.name }),
...(parsedInput.description !== undefined && { description: parsedInput.description }),
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
withAuditLogging(
"deleted",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
await prisma.dashboard.delete({
where: { id: parsedInput.dashboardId },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
return { success: true };
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
_count: { select: { widgets: true } },
},
});
}
);
const ZGetDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const getDashboardAction = authenticatedActionClient
.schema(ZGetDashboardAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
},
},
},
},
},
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
return dashboard;
}
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
chartId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient
.schema(ZAddChartToDashboardAction)
.action(
withAuditLogging(
"created",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const [chart, dashboard] = await Promise.all([
prisma.chart.findFirst({ where: { id: parsedInput.chartId, projectId } }),
prisma.dashboard.findFirst({ where: { id: parsedInput.dashboardId, projectId } }),
]);
if (!chart) {
throw new Error("Chart not found");
}
if (!dashboard) {
throw new Error("Dashboard not found");
}
const maxOrder = await prisma.dashboardWidget.aggregate({
where: { dashboardId: parsedInput.dashboardId },
_max: { order: true },
});
const widget = await prisma.dashboardWidget.create({
data: {
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
title: parsedInput.title,
layout: parsedInput.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);

View File

@@ -0,0 +1,4 @@
import { z } from "zod";
export const ZChartType = z.enum(["area", "bar", "line", "pie", "big_number"]);
export type TChartType = z.infer<typeof ZChartType>;

View File

@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { executeQuery } from "./cube-client";
const mockLoad = vi.fn();
const mockTablePivot = vi.fn();
vi.mock("@cubejs-client/core", () => ({
default: vi.fn(() => ({
load: mockLoad,
})),
}));
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
const resultSet = { tablePivot: mockTablePivot };
mockLoad.mockResolvedValue(resultSet);
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
});
test("loads query and returns tablePivot result", async () => {
const query = { measures: ["FeedbackRecords.count"] };
const result = await executeQuery(query);
expect(mockLoad).toHaveBeenCalledWith(query);
expect(mockTablePivot).toHaveBeenCalled();
expect(result).toEqual([{ id: "1", count: 42 }]);
});
});

View File

@@ -0,0 +1,25 @@
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
const getApiUrl = (): string => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
let cubeClient: CubeApi | null = null;
function getCubeClient(): CubeApi {
if (!cubeClient) {
const token = process.env.CUBEJS_API_TOKEN ?? "";
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
}
return cubeClient;
}
export async function executeQuery(query: Query) {
const client = getCubeClient();
const resultSet = await client.load(query);
return resultSet.tablePivot();
}

View File

@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",

View File

@@ -23,6 +23,7 @@
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0",
"@boxyhq/saml-jackson": "1.52.2",
"@cubejs-client/core": "1.6.6",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",

12
cube/cube.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
// queryRewrite runs before every Cube query. Use it to enforce row-level security (RLS)
// by injecting filters based on the caller's identity (e.g. organizationId, projectId).
//
// The securityContext is populated from the decoded JWT passed via the API token.
// Currently a passthrough because access control is handled in the Next.js API layer
// before reaching Cube. When Cube is exposed more broadly or multi-tenancy enforcement
// is needed at the Cube level, add filters here based on securityContext claims.
queryRewrite: (query, { securityContext }) => {
return query;
},
};

View File

@@ -0,0 +1,159 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userIdentifier: {
sql: `user_identifier`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `feedback_record_id || '-' || topic`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});

View File

@@ -36,6 +36,34 @@ services:
volumes:
- minio-data:/data
# Cube connects to the Formbricks Hub Postgres which owns the feedback_records table.
# The CUBEJS_DB_* defaults below must match the Hub's Postgres credentials.
# Override via env vars if your Hub database uses different credentials or runs on another host.
#
# SECURITY: CUBEJS_API_SECRET has no default and must be set explicitly (e.g. in .env).
# Never use a weak secret in production/staging. Generate with: openssl rand -hex 32
cube:
image: cubejs/cube:v1.6.6
ports:
- 4000:4000
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./cube/cube.js:/cube/conf/cube.js
- ./cube/schema:/cube/conf/model
restart: on-failure
depends_on:
- postgres
volumes:
postgres:
driver: local

View File

@@ -4,182 +4,12 @@ description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---
## v4.7
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
### What Happens Automatically
When Formbricks v4.7 starts for the first time, the data migration will:
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
<Info>
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
handles everything automatically and you can skip the manual backfill step below.
</Info>
### Steps to Migrate
**1. Backup your Database**
<Tabs>
<Tab title="Docker">
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
</Tab>
<Tab title="Kubernetes">
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
```bash
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
</Info>
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
</Tab>
</Tabs>
**2. Upgrade to Formbricks v4.7**
<Tabs>
<Tab title="Docker">
```bash
# Pull the latest version
docker compose pull
# Stop the current instance
docker compose down
# Start with Formbricks v4.7
docker compose up -d
```
</Tab>
<Tab title="Kubernetes">
```bash
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
-n formbricks \
--set deployment.image.tag=v4.7.0
```
<Info>
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
PreSync hook before the new pods start. No manual migration step is needed.
</Info>
</Tab>
</Tabs>
**3. Check the Migration Logs**
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
<Tabs>
<Tab title="Docker">
```bash
docker compose logs formbricks | grep -i "backfill"
```
</Tab>
<Tab title="Kubernetes">
```bash
# Check the application pod logs
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
```
If the Helm migration Job ran, you can also inspect its logs:
```bash
kubectl logs -n formbricks job/formbricks-migration
```
</Tab>
</Tabs>
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
**4. Run the Backfill Script (large datasets only)**
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
<Tabs>
<Tab title="Docker">
```bash
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
</Tab>
<Tab title="Kubernetes">
```bash
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
</Info>
</Tab>
</Tabs>
The script will output progress as it runs:
```
========================================
Attribute Value Backfill Script
========================================
Fetching number-type attribute keys...
Found 12 number-type keys. Backfilling valueNumber...
Number backfill progress: 10/12 keys (48230 rows updated)
Number backfill progress: 12/12 keys (52104 rows updated)
Fetching date-type attribute keys...
Found 5 date-type keys. Backfilling valueDate...
Date backfill progress: 5/5 keys (31200 rows updated)
========================================
Backfill Complete!
========================================
valueNumber rows updated: 52104
valueDate rows updated: 31200
Duration: 42.3s
========================================
```
Key characteristics of the backfill script:
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
**5. Verify the Upgrade**
- Access your Formbricks instance at the same URL as before
- If you use contact segments with number or date filters, verify they return the expected results
- Check that existing surveys and response data are intact
---
## v4.0
<Warning>
**Important: Migration Required**
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
</Warning>
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
@@ -187,11 +17,9 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
### What's New in Formbricks 4.0
**🚀 New Enterprise Features:**
- **Quotas Management**: Advanced quota controls for enterprise users
**🏗️ Technical Foundation Improvements:**
- **Enhanced File Storage**: Improved file handling with better performance and reliability
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
- **Database Optimization**: Removal of unused database tables and fields for better performance
@@ -211,8 +39,7 @@ These services are already included in the updated one-click setup for self-host
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Advanced features** that require sophisticated caching and file processing
- **Better performance** through optimized, dedicated services
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
@@ -225,7 +52,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
### One-Click Setup
For users using our official one-click setup, we provide an automated migration using a migration script:
For users using our official one-click setup, we provide an automated migration using a migration script:
```bash
# Download the latest script
@@ -240,11 +67,11 @@ chmod +x migrate-to-v4.sh
```
This script guides you through the steps for the infrastructure migration and does the following:
- Adds a Redis service to your setup and configures it
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
- Pulls the latest Formbricks image and updates your instance
### Manual Setup
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
@@ -260,7 +87,6 @@ You need to configure the `REDIS_URL` environment variable and point it to your
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
Formbricks supports multiple storage providers (among many other S3-compatible storages):
- AWS S3
- Digital Ocean Spaces
- Hetzner Object Storage
@@ -275,7 +101,6 @@ Please make sure to set up a storage bucket with one of these solutions and then
S3_BUCKET_NAME: formbricks-uploads
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
```
#### Upgrade Process
**1. Backup your Database**
@@ -287,8 +112,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
If you run into "**No such container**", use `docker ps` to find your container name,
e.g. `formbricks_postgres_1`.
</Info>
**2. Upgrade to Formbricks 4.0**
@@ -309,7 +134,6 @@ docker compose up -d
**3. Automatic Database Migration**
When you start Formbricks 4.0 for the first time, it will **automatically**:
- Detect and apply required database schema updates
- Remove unused database tables and fields
- Optimize the database structure for better performance

View File

@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */
import { type TActionClassNoCodeConfig } from "../types/action-classes";
import type { TOrganizationAccess } from "../types/api-key";
import type { TChartConfig, TChartQuery, TWidgetLayout } from "../types/dashboard";
import { type TIntegrationConfig } from "../types/integration";
import { type TOrganizationBilling } from "../types/organizations";
import { type TProjectConfig, type TProjectStyling } from "../types/project";
@@ -55,5 +56,8 @@ declare global {
export type OrganizationAccess = TOrganizationAccess;
export type SurveyMetadata = TSurveyMetadata;
export type SurveyQuotaLogic = TSurveyQuotaLogic;
export type ChartQuery = TChartQuery;
export type ChartConfig = TChartConfig;
export type WidgetLayout = TWidgetLayout;
}
}

View File

@@ -0,0 +1,77 @@
-- CreateEnum
CREATE TYPE "public"."ChartType" AS ENUM ('area', 'bar', 'line', 'pie', 'big_number');
-- CreateTable
CREATE TABLE "public"."Chart" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" "public"."ChartType" NOT NULL,
"projectId" TEXT NOT NULL,
"query" JSONB NOT NULL DEFAULT '{}',
"config" JSONB NOT NULL DEFAULT '{}',
"createdBy" TEXT,
CONSTRAINT "Chart_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Dashboard" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"projectId" TEXT NOT NULL,
"createdBy" TEXT,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."DashboardWidget" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"dashboardId" TEXT NOT NULL,
"title" TEXT,
"chartId" TEXT NOT NULL,
"layout" JSONB NOT NULL DEFAULT '{"x":0,"y":0,"w":4,"h":3}',
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DashboardWidget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Chart_projectId_created_at_idx" ON "public"."Chart"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Chart_projectId_name_key" ON "public"."Chart"("projectId", "name");
-- CreateIndex
CREATE INDEX "Dashboard_projectId_created_at_idx" ON "public"."Dashboard"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Dashboard_projectId_name_key" ON "public"."Dashboard"("projectId", "name");
-- CreateIndex
CREATE INDEX "DashboardWidget_dashboardId_order_idx" ON "public"."DashboardWidget"("dashboardId", "order");
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_chartId_fkey" FOREIGN KEY ("chartId") REFERENCES "public"."Chart"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -647,6 +647,8 @@ model Project {
logo Json?
projectTeams ProjectTeam[]
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
charts Chart[]
dashboards Dashboard[]
@@unique([organizationId, name])
}
@@ -867,6 +869,8 @@ model User {
/// [Locale]
locale String @default("en-US")
surveys Survey[]
charts Chart[] @relation("chartCreatedBy")
dashboards Dashboard[] @relation("dashboardCreatedBy")
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
@@ -1004,3 +1008,92 @@ model ProjectTeam {
@@id([projectId, teamId])
@@index([teamId])
}
enum ChartType {
area
bar
line
pie
big_number
}
/// Represents a chart/visualization that can be used in multiple dashboards.
/// Charts are reusable components that query analytics data.
///
/// @property id - Unique identifier for the chart
/// @property name - Display name of the chart
/// @property type - Type of visualization (bar, line, pie, etc.)
/// @property project - The project this chart belongs to
/// @property query - Cube.js query configuration (JSON)
/// @property config - Chart-specific configuration (colors, labels, etc.)
/// @property createdBy - User who created the chart
/// @property dashboards - Dashboards that use this chart
model Chart {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type ChartType
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
/// [ChartQuery] - Cube.js query configuration
query Json @default("{}")
/// [ChartConfig] - Visualization configuration (colors, labels, formatting)
config Json @default("{}")
creator User? @relation("chartCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a dashboard containing multiple charts.
/// Dashboards aggregate analytics insights at the project level.
///
/// @property id - Unique identifier for the dashboard
/// @property name - Display name of the dashboard
/// @property description - Optional description
/// @property project - The project this dashboard belongs to
/// @property widgets - Charts on this dashboard
/// @property createdBy - User who created the dashboard
model Dashboard {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
description String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
creator User? @relation("dashboardCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a chart widget on a dashboard.
/// Widgets are positioned using a grid layout system.
///
/// @property id - Unique identifier for the widget
/// @property dashboard - The dashboard this widget belongs to
/// @property title - Optional title for the widget
/// @property chart - The chart displayed in this widget
/// @property layout - Grid layout configuration (x, y, width, height)
/// @property order - Display order within the dashboard
model DashboardWidget {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
dashboardId String
title String?
chart Chart @relation(fields: [chartId], references: [id], onDelete: Cascade)
chartId String
/// [WidgetLayout] - Grid layout: { x, y, w, h }
layout Json @default("{\"x\":0,\"y\":0,\"w\":4,\"h\":3}")
order Int @default(0)
@@index([dashboardId, order])
}

View File

@@ -0,0 +1,75 @@
import { z } from "zod";
// ── Cube.js Query shape (stored as JSON in Chart.query) ─────────────────────
// Mirrors https://cube.dev/docs/product/apis-integrations/core-data-apis/rest-api/query-format
const ZCubeTimeDimension = z.object({
dimension: z.string(),
dateRange: z.union([z.string(), z.tuple([z.string(), z.string()])]).optional(),
compareDateRange: z.array(z.union([z.string(), z.tuple([z.string(), z.string()])])).optional(),
granularity: z.enum(["second", "minute", "hour", "day", "week", "month", "quarter", "year"]).optional(),
});
const ZCubeMemberFilter = z.object({
member: z.string(),
operator: z.string(),
values: z.array(z.string()).optional(),
});
type TCubeFilter = z.infer<typeof ZCubeMemberFilter> | { and: TCubeFilter[] } | { or: TCubeFilter[] };
const ZCubeFilter: z.ZodType<TCubeFilter> = z.union([
ZCubeMemberFilter,
z.object({ and: z.lazy(() => z.array(ZCubeFilter)) }),
z.object({ or: z.lazy(() => z.array(ZCubeFilter)) }),
]);
export const ZChartQuery = z.object({
measures: z.array(z.string()).optional(),
dimensions: z.array(z.string()).optional(),
segments: z.array(z.string()).optional(),
timeDimensions: z.array(ZCubeTimeDimension).optional(),
filters: z.array(ZCubeFilter).optional(),
order: z
.union([z.array(z.tuple([z.string(), z.enum(["asc", "desc"])])), z.record(z.enum(["asc", "desc"]))])
.optional(),
limit: z.number().int().positive().optional(),
total: z.boolean().optional(),
offset: z.number().int().nonnegative().optional(),
timezone: z.string().optional(),
renewQuery: z.boolean().optional(),
ungrouped: z.boolean().optional(),
joinHints: z.array(z.tuple([z.string(), z.string()])).optional(),
});
export type TChartQuery = z.infer<typeof ZChartQuery>;
// ── Chart visualization config (stored as JSON in Chart.config) ─────────────
export const ZChartConfig = z.object({
colors: z.array(z.string()).optional(),
xAxisLabel: z.string().optional(),
yAxisLabel: z.string().optional(),
showLegend: z.boolean().optional(),
legendPosition: z.enum(["top", "bottom", "left", "right"]).optional(),
stacked: z.boolean().optional(),
showGrid: z.boolean().optional(),
showValues: z.boolean().optional(),
numberFormat: z.string().optional(),
dateFormat: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
});
export type TChartConfig = z.infer<typeof ZChartConfig>;
// ── Widget grid layout (stored as JSON in DashboardWidget.layout) ───────────
export const ZWidgetLayout = z.object({
x: z.number().int().nonnegative(),
y: z.number().int().nonnegative(),
w: z.number().int().positive(),
h: z.number().int().positive(),
});
export type TWidgetLayout = z.infer<typeof ZWidgetLayout>;

73
pnpm-lock.yaml generated
View File

@@ -28,7 +28,7 @@ importers:
dependencies:
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -139,6 +139,9 @@ importers:
'@boxyhq/saml-jackson':
specifier: 1.52.2
version: 1.52.2(socks@2.8.7)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3))
'@cubejs-client/core':
specifier: 1.6.6
version: 1.6.6(encoding@0.1.13)
'@dnd-kit/core':
specifier: 6.3.1
version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -294,7 +297,7 @@ importers:
version: 1.2.6(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@sentry/nextjs':
specifier: 10.5.0
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
@@ -384,13 +387,13 @@ importers:
version: 3.0.1
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-auth:
specifier: 4.24.12
version: 4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-safe-action:
specifier: 7.10.8
version: 7.10.8(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
version: 7.10.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -1775,6 +1778,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@cubejs-client/core@1.6.6':
resolution: {integrity: sha512-JWEVQaaS7y6Y3Nhe4Lcjl8coP5eXIgYZQTb2WMlLdMluSGM4mWOSFpAis77lAPV+nybFvRf9ri+PjCGqGJXH5g==}
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
@@ -7155,6 +7161,9 @@ packages:
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -7170,6 +7179,9 @@ packages:
engines: {node: '>=20'}
hasBin: true
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -10035,6 +10047,9 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
ramda@0.27.2:
resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -11306,6 +11321,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
url-search-params-polyfill@7.0.1:
resolution: {integrity: sha512-bAw7L2E+jn9XHG5P9zrPnHdO0yJub4U+yXJOdpcpkr7OBd9T8oll4lUos0iSGRcDvfZoLUKfx9a6aNmIhJ4+mQ==}
url-template@2.0.8:
resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
@@ -13515,6 +13533,17 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@cubejs-client/core@1.6.6(encoding@0.1.13)':
dependencies:
core-js: 3.48.0
cross-fetch: 3.2.0(encoding@0.1.13)
dayjs: 1.11.19
ramda: 0.27.2
url-search-params-polyfill: 7.0.1
uuid: 11.1.0
transitivePeerDependencies:
- encoding
'@date-fns/tz@1.2.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
@@ -17049,7 +17078,7 @@ snapshots:
'@sentry/core@10.5.0': {}
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.38.0
@@ -17062,7 +17091,7 @@ snapshots:
'@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.12))
chalk: 3.0.0
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
resolve: 1.22.8
rollup: 4.54.0
stacktrace-parser: 0.1.11
@@ -19786,6 +19815,8 @@ snapshots:
dependencies:
browserslist: 4.28.1
core-js@3.48.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@@ -19800,6 +19831,12 @@ snapshots:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-fetch@3.2.0(encoding@0.1.13):
dependencies:
node-fetch: 2.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -22186,13 +22223,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next-auth@4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
@@ -22203,9 +22240,9 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.11
next-safe-action@7.10.8(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
next-safe-action@7.10.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
dependencies:
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
@@ -22219,7 +22256,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.10
'@next/swc-darwin-x64': 16.0.10
@@ -22236,7 +22273,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
@@ -22245,7 +22282,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
@@ -22933,6 +22970,8 @@ snapshots:
quick-format-unescaped@4.0.4: {}
ramda@0.27.2: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -23991,12 +24030,10 @@ snapshots:
stubborn-utils@1.0.2: {}
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3):
styled-jsx@5.1.6(react@19.2.3):
dependencies:
client-only: 0.0.1
react: 19.2.3
optionalDependencies:
'@babel/core': 7.28.5
stylis@4.3.6: {}
@@ -24504,6 +24541,8 @@ snapshots:
dependencies:
punycode: 2.3.1
url-search-params-polyfill@7.0.1: {}
url-template@2.0.8: {}
use-callback-ref@1.3.3(@types/react@19.2.1)(react@19.2.1):

View File

@@ -156,6 +156,8 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"CUBEJS_API_TOKEN",
"CUBEJS_API_URL",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",