Files
formbricks/apps/web/modules/survey/editor/actions.ts
T
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00

344 lines
12 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { POSTHOG_KEY, UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromProjectId,
getOrganizationIdFromSurveyId,
getProjectIdFromEnvironmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
import { updateSurvey, updateSurveyDraft } from "@/modules/survey/editor/lib/survey";
import { ZSurveyDraft } from "@/modules/survey/editor/types/survey";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getProject, getProjectLanguages } from "./lib/project";
/**
* Checks if survey follow-ups can be added for the given organization.
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
* Only throws when new follow-ups are being added.
*/
const checkSurveyFollowUpsPermission = async (
organizationId: string,
newFollowUpIds: string[],
oldFollowUpIds: Set<string>
): Promise<void> => {
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (isSurveyFollowUpsEnabled) return;
for (const id of newFollowUpIds) {
if (!oldFollowUpIds.has(id)) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
}
};
export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSurveyDraft).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
// Cast to TSurvey - ZSurveyDraft validates structure, full validation happens on publish
const survey = parsedInput as TSurvey;
const organizationId = await getOrganizationIdFromSurveyId(survey.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(survey.id),
minPermission: "readWrite",
},
],
});
if (survey.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = survey.id;
const oldObject = await getSurvey(survey.id);
if (survey.followUps.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
survey.followUps.map((f) => f.id),
oldFollowUpIds
);
}
await checkExternalUrlsPermission(organizationId, survey, oldObject);
// Use the draft version that skips validation
const result = await updateSurveyDraft(survey);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
return result;
})
);
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.id;
const oldObject = await getSurvey(parsedInput.id);
if (parsedInput.followUps?.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
parsedInput.followUps.map((f) => f.id),
oldFollowUpIds
);
}
// Check external URLs permission (with grandfathering)
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
const result = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
if (POSTHOG_KEY && result.status !== "draft") {
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
const posthogEventMetadata = {
survey_id: result.id,
survey_type: result.type,
question_count: getElementsFromBlocks(result.blocks).length,
organization_id: organizationId,
has_targeting: result.segment ? !result.segment.isPrivate : false,
language_count: result.languages?.length ?? 0,
};
if (isPublish) {
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata);
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
} else {
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
}
}
revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
return result;
})
);
const ZRefetchProjectAction = z.object({
projectId: z.cuid2(),
});
export const refetchProjectAction = authenticatedActionClient
.inputSchema(ZRefetchProjectAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromProjectId(parsedInput.projectId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
return await getProject(parsedInput.projectId);
});
const ZGetProjectLanguagesAction = z.object({
projectId: ZId,
});
export const getProjectLanguagesAction = authenticatedActionClient
.inputSchema(ZGetProjectLanguagesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromProjectId(parsedInput.projectId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: parsedInput.projectId,
},
],
});
return await getProjectLanguages(parsedInput.projectId);
});
const ZGetImagesFromUnsplashAction = z.object({
searchQuery: z.string(),
page: z.number().optional(),
});
export const getImagesFromUnsplashAction = actionClient
.inputSchema(ZGetImagesFromUnsplashAction)
.action(async ({ parsedInput }) => {
if (!UNSPLASH_ACCESS_KEY) {
throw new Error("Unsplash access key is not set");
}
const baseUrl = "https://api.unsplash.com/search/photos";
const params = new URLSearchParams({
query: parsedInput.searchQuery,
client_id: UNSPLASH_ACCESS_KEY,
orientation: "landscape",
per_page: "9",
page: (parsedInput.page || 1).toString(),
});
const response = await fetch(`${baseUrl}?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to fetch images from Unsplash");
}
const { results } = await response.json();
return results.map(
(result: {
id: string;
alt_description: string;
user: { first_name: string; last_name: string; links: { html: string } };
urls: { regular: string };
links: { download_location: string };
}) => {
const authorName = encodeURIComponent(result.user.first_name + " " + result.user.last_name);
const authorLink = encodeURIComponent(result.user.links.html);
return {
id: result.id,
alt_description: result.alt_description,
urls: {
regularWithAttribution: `${result.urls.regular}&dpr=2&authorLink=${authorLink}&authorName=${authorName}&utm_source=formbricks&utm_medium=referral`,
download: result.links.download_location,
},
};
}
);
});
const isValidUnsplashUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "https:" && UNSPLASH_ALLOWED_DOMAINS.includes(parsedUrl.hostname);
} catch {
return false;
}
};
const ZTriggerDownloadUnsplashImageAction = z.object({
downloadUrl: z.url(),
});
export const triggerDownloadUnsplashImageAction = actionClient
.inputSchema(ZTriggerDownloadUnsplashImageAction)
.action(async ({ parsedInput }) => {
if (!isValidUnsplashUrl(parsedInput.downloadUrl)) {
throw new Error("Invalid Unsplash URL");
}
const response = await fetch(`${parsedInput.downloadUrl}/?client_id=${UNSPLASH_ACCESS_KEY}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to download image from Unsplash");
}
});
const ZCreateActionClassAction = z.object({
action: ZActionClassInput,
});
export const createActionClassAction = authenticatedActionClient.inputSchema(ZCreateActionClassAction).action(
withAuditLogging("created", "actionClass", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.action.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.action.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createActionClass(parsedInput.action.environmentId, parsedInput.action);
ctx.auditLoggingCtx.actionClassId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);