Compare commits

...

4 Commits

Author SHA1 Message Date
TheodorTomas
d2038d9770 chore: adding seed data for chards, dashboards and dashboar widgets 2026-02-19 21:05:49 +07:00
TheodorTomas
3e7fc6610a fix: update HasFindMany type 2026-02-19 20:57:35 +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
7 changed files with 820 additions and 2 deletions

View File

@@ -12,7 +12,10 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| 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

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

View File

@@ -93,6 +93,9 @@ async function deleteData(): Promise<void> {
"segment",
"webhook",
"integration",
"dashboardWidget",
"chart",
"dashboard",
"projectTeam",
"teamUser",
"team",
@@ -570,8 +573,213 @@ async function main(): Promise<void> {
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
// Charts & Dashboards
logger.info("Seeding charts and dashboards...");
const chartResponsesOverTime = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_RESPONSES_OVER_TIME },
update: {},
create: {
id: SEED_IDS.CHART_RESPONSES_OVER_TIME,
name: "Responses Over Time",
type: "line",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.createdAt", granularity: "week" },
],
},
config: {
xAxisLabel: "Week",
yAxisLabel: "Responses",
showGrid: true,
showLegend: false,
},
},
});
const chartSatisfactionDist = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_SATISFACTION_DIST },
update: {},
create: {
id: SEED_IDS.CHART_SATISFACTION_DIST,
name: "Satisfaction Distribution",
type: "pie",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.rating"],
},
config: {
showLegend: true,
legendPosition: "right",
colors: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#10b981"],
},
},
});
const chartNpsScore = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_NPS_SCORE },
update: {},
create: {
id: SEED_IDS.CHART_NPS_SCORE,
name: "NPS Score",
type: "big_number",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.npsScore"],
},
config: {
prefix: "",
suffix: "",
numberFormat: "0",
},
},
});
const chartCompletionRate = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_COMPLETION_RATE },
update: {},
create: {
id: SEED_IDS.CHART_COMPLETION_RATE,
name: "Survey Completion Rate",
type: "bar",
projectId: project.id,
createdBy: SEED_IDS.USER_MANAGER,
query: {
measures: ["FeedbackRecords.completionRate"],
dimensions: ["FeedbackRecords.surveyName"],
},
config: {
xAxisLabel: "Survey",
yAxisLabel: "Completion %",
showValues: true,
showGrid: true,
colors: ["#6366f1"],
},
},
});
const chartTopChannels = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_TOP_CHANNELS },
update: {},
create: {
id: SEED_IDS.CHART_TOP_CHANNELS,
name: "Responses by Channel",
type: "area",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.channel"],
timeDimensions: [
{ dimension: "FeedbackRecords.createdAt", granularity: "month" },
],
},
config: {
stacked: true,
showLegend: true,
legendPosition: "bottom",
showGrid: true,
},
},
});
// Dashboard: Overview
const dashboardOverview = await prisma.dashboard.upsert({
where: { id: SEED_IDS.DASHBOARD_OVERVIEW },
update: {},
create: {
id: SEED_IDS.DASHBOARD_OVERVIEW,
name: "Overview",
description: "High-level metrics across all surveys",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
},
});
// Dashboard: Survey Performance
const dashboardSurveyPerf = await prisma.dashboard.upsert({
where: { id: SEED_IDS.DASHBOARD_SURVEY_PERF },
update: {},
create: {
id: SEED_IDS.DASHBOARD_SURVEY_PERF,
name: "Survey Performance",
description: "Detailed survey completion and response metrics",
projectId: project.id,
createdBy: SEED_IDS.USER_MANAGER,
},
});
// Widgets for Overview dashboard
await prisma.dashboardWidget.createMany({
skipDuplicates: true,
data: [
{
dashboardId: dashboardOverview.id,
chartId: chartNpsScore.id,
title: "Current NPS",
layout: { x: 0, y: 0, w: 2, h: 2 },
order: 0,
},
{
dashboardId: dashboardOverview.id,
chartId: chartResponsesOverTime.id,
title: "Weekly Responses",
layout: { x: 2, y: 0, w: 4, h: 3 },
order: 1,
},
{
dashboardId: dashboardOverview.id,
chartId: chartSatisfactionDist.id,
title: "Satisfaction Breakdown",
layout: { x: 0, y: 3, w: 3, h: 3 },
order: 2,
},
{
dashboardId: dashboardOverview.id,
chartId: chartTopChannels.id,
title: "Channel Trends",
layout: { x: 3, y: 3, w: 3, h: 3 },
order: 3,
},
],
});
// Widgets for Survey Performance dashboard
await prisma.dashboardWidget.createMany({
skipDuplicates: true,
data: [
{
dashboardId: dashboardSurveyPerf.id,
chartId: chartCompletionRate.id,
title: "Completion by Survey",
layout: { x: 0, y: 0, w: 6, h: 3 },
order: 0,
},
{
dashboardId: dashboardSurveyPerf.id,
chartId: chartResponsesOverTime.id,
title: "Response Volume",
layout: { x: 0, y: 3, w: 4, h: 3 },
order: 1,
},
{
dashboardId: dashboardSurveyPerf.id,
chartId: chartNpsScore.id,
title: "NPS Tracker",
layout: { x: 4, y: 3, w: 2, h: 2 },
order: 2,
},
],
});
logger.info(`\n${"=".repeat(50)}`);
logger.info("🚀 SEEDING COMPLETED SUCCESSFULLY");
logger.info("SEEDING COMPLETED SUCCESSFULLY");
logger.info("=".repeat(50));
logger.info("\nLog in with the following credentials:");
logger.info(`\n Admin (Owner):`);

View File

@@ -10,6 +10,13 @@ export const SEED_IDS = {
SURVEY_CSAT: "clseedsurveycsat000000",
SURVEY_DRAFT: "clseedsurveydraft00000",
SURVEY_COMPLETED: "clseedsurveycomplete00",
CHART_RESPONSES_OVER_TIME: "clseedchartresptime00",
CHART_SATISFACTION_DIST: "clseedchartsatdist000",
CHART_NPS_SCORE: "clseedchartnpsscore00",
CHART_COMPLETION_RATE: "clseedchartcomplete00",
CHART_TOP_CHANNELS: "clseedcharttopchann00",
DASHBOARD_OVERVIEW: "clseeddashovervieww00",
DASHBOARD_SURVEY_PERF: "clseeddashsurvperf000",
} as const;
export const SEED_CREDENTIALS = {