Compare commits

...

10 Commits

Author SHA1 Message Date
Dhruwang 264139e829 Merge branch 'main' of https://github.com/formbricks/formbricks into fix-hungarian-translation-update-260306 2026-03-09 17:45:52 +05:30
Anshuman Pandey 96f173c3b1 fix: overrides packages for CVE fixes (#7442) 2026-03-09 09:55:02 +00:00
Balázs Úr 393eaeaf80 update Hungarian translations 2026-03-09 10:35:18 +01:00
Balázs Úr 5def9742aa Merge branch 'main' into fix-hungarian-translation-update-260306 2026-03-09 10:32:45 +01:00
Harsh Bhat 9c9e55fba6 docs: add keycloack docs (#7440) 2026-03-09 08:38:00 +00:00
Johannes 42541f86fd feat(navigation): add workflows section to main navigation and update… (#7392) 2026-03-08 18:13:38 +00:00
Matti Nannt 0ba469a73d fix: pin fast-xml-parser to 5.3.5 (#7436) 2026-03-06 20:20:34 +01:00
Matti Nannt afa192e5b9 chore: upgrade deps and Zod v4 migration (#7425)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 14:41:28 +01:00
Balázs Úr cba7bd8c8a fix: Hungarian translations 2026-03-06 14:11:19 +01:00
Bhagya Amarasinghe 4860a9a5cf fix: helm template duplicate label key in migration-job (#7431)
Co-authored-by: Rob <178471500+rob-htl@users.noreply.github.com>
2026-03-06 11:48:07 +00:00
241 changed files with 6155 additions and 5190 deletions
+6 -6
View File
@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.14",
"@storybook/addon-links": "10.2.14",
"@storybook/addon-onboarding": "10.2.14",
"@storybook/react-vite": "10.2.14",
"@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.56.1",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14",
"storybook": "10.2.14",
"storybook": "10.2.15",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.14"
"@storybook/addon-docs": "10.2.15"
}
}
@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.string().cuid2(),
id: z.cuid2(),
name: z.string(),
});
@@ -25,7 +25,7 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
@@ -97,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction)
.inputSchema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -122,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction)
.inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -11,6 +11,7 @@ import {
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -114,6 +115,13 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
@@ -121,7 +129,7 @@ export const MainNavigation = ({
isActive: pathname?.includes("/project"),
},
],
[t, environment.id, pathname]
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [
@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
});
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.inputSchema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
@@ -63,7 +63,7 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
@@ -17,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
});
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
@@ -55,28 +55,36 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging(
"deleted",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
});
export const getResponsesAction = authenticatedActionClient
.schema(ZGetResponsesAction)
.inputSchema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
});
export const getSurveySummaryAction = authenticatedActionClient
.schema(ZGetSurveySummaryAction)
.inputSchema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
});
export const getResponseCountAction = authenticatedActionClient
.schema(ZGetResponseCountAction)
.inputSchema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction)
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -22,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
});
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.schema(ZSendEmbedSurveyPreviewEmailAction)
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -69,7 +69,7 @@ const ZResetSurveyAction = z.object({
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
@@ -123,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
});
export const getEmailHtmlAction = authenticatedActionClient
.schema(ZGetEmailHtmlAction)
.inputSchema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -152,7 +152,7 @@ const ZGeneratePersonalLinksAction = z.object({
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
@@ -231,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.inputSchema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -1095,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[cursor, z.cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
@@ -28,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
});
export const getResponsesDownloadUrlAction = authenticatedActionClient
.schema(ZGetResponsesDownloadUrlAction)
.inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -58,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
});
export const getSurveyFilterDataAction = authenticatedActionClient
.schema(ZGetSurveyFilterDataAction)
.inputSchema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId);
@@ -121,7 +121,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
@@ -0,0 +1,208 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="from-brand-light to-brand-dark mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:outline-none focus:ring-2"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="text-brand-dark h-6 w-6" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -0,0 +1,39 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={organization.billing.plan}
/>
);
};
export default Page;
@@ -21,7 +21,7 @@ const ZCreateOrUpdateIntegrationAction = z.object({
});
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.inputSchema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging(
"createdUpdated",
@@ -67,7 +67,7 @@ const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
@@ -17,7 +17,7 @@ const ZValidateGoogleSheetsConnectionAction = z.object({
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.inputSchema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -51,7 +51,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
});
export const getSpreadsheetNameByIdAction = authenticatedActionClient
.schema(ZGetSpreadsheetNameByIdAction)
.inputSchema(ZGetSpreadsheetNameByIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
});
export const getSlackChannelsAction = authenticatedActionClient
.schema(ZGetSlackChannelsAction)
.inputSchema(ZGetSlackChannelsAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -50,7 +50,7 @@ export const GET = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID v1 format detected"
);
@@ -6,7 +6,7 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.string().cuid2()]);
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
@@ -101,7 +101,9 @@ describe("verifyRecaptchaToken", () => {
},
signal: {},
};
vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any);
vi.spyOn(global, "AbortController").mockImplementation(function AbortController() {
return abortController as any;
});
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
verifyRecaptchaToken("token", 0.5);
vi.advanceTimersByTime(5000);
+1 -5
View File
@@ -1,4 +1,4 @@
import cuid2 from "@paralleldrive/cuid2";
import * as cuid2 from "@paralleldrive/cuid2";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
@@ -20,10 +20,6 @@ vi.mock("@paralleldrive/cuid2", () => {
const isCuidMock = vi.fn();
return {
default: {
createId: createIdMock,
isCuid: isCuidMock,
},
createId: createIdMock,
isCuid: isCuidMock,
};
+3 -3
View File
@@ -1,10 +1,10 @@
import cuid2 from "@paralleldrive/cuid2";
import { createId, isCuid } from "@paralleldrive/cuid2";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = cuid2.createId();
const cuid = createId();
if (!isEncrypted) {
return cuid;
}
@@ -30,7 +30,7 @@ export const validateSurveySingleUseId = (surveySingleUseId: string): string | u
return undefined;
}
if (cuid2.isCuid(decryptedCuid)) {
if (isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;
@@ -14,31 +14,39 @@ const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
export const createOrganizationAction = authenticatedActionClient.schema(ZCreateOrganizationAction).action(
withAuditLogging(
"created",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
export const createOrganizationAction = authenticatedActionClient
.inputSchema(ZCreateOrganizationAction)
.action(
withAuditLogging(
"created",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
return newOrganization;
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
return newOrganization;
}
)
);
)
);
+13
View File
@@ -369,6 +369,7 @@ checksums:
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
common/size: 227fadeeff951e041ff42031a11a4626
common/skip: b7f28dfa2f58b80b149bb82b392d0291
common/skipped: d496f0f667e1b4364b954db71335d4ef
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
@@ -438,6 +439,7 @@ checksums:
common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
@@ -3107,3 +3109,14 @@ checksums:
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_placeholder: 0c26f9e4f82429acb2ac7525a3e8f24e
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
workflows/placeholder: 0d24da3af3b860b8f943c83efdeef227
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
workflows/thank_you_description: 842579609c6bf16a1d6c57a333fd5125
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
+1 -1
View File
@@ -159,7 +159,7 @@ export const BREVO_LIST_ID = env.BREVO_LIST_ID;
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2024-06-20";
export const STRIPE_API_VERSION = "2026-02-25.clover";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
+2 -2
View File
@@ -71,8 +71,8 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
validateInputs(
[surveyId, ZId],
[limit, z.number().int().min(1).optional()],
[offset, z.number().int().nonnegative().optional()]
[limit, z.int().min(1).optional()],
[offset, z.int().nonnegative().optional()]
);
try {
+10 -14
View File
@@ -14,7 +14,7 @@ export const env = createEnv({
CRON_SECRET: z.string().optional(),
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.string().url(),
DATABASE_URL: z.url(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -23,7 +23,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).default("production"),
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -31,21 +31,20 @@ export const env = createEnv({
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(),
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(),
IMPRINT_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
CHATWOOT_BASE_URL: z.url().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
MAIL_FROM: z.email().optional(),
NEXTAUTH_URL: z.url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -58,10 +57,9 @@ export const env = createEnv({
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
PRIVACY_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
@@ -86,7 +84,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
PUBLIC_URL: z
.string()
.url()
.refine(
(url) => {
@@ -98,12 +95,11 @@ export const env = createEnv({
}
},
{
message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
error: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
}
)
.optional(),
TERMS_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
@@ -112,7 +108,7 @@ export const env = createEnv({
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
+1 -1
View File
@@ -267,7 +267,7 @@ export const getResponses = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[cursor, z.cuid2().optional()]
);
limit = limit ?? RESPONSES_PER_PAGE;
+39 -44
View File
@@ -11,6 +11,7 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -22,15 +23,6 @@ import {
validateMediaAndPrepareBlocks,
} from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
deleteMany?: {
actionClassId: {
in: string[];
};
};
}
export const selectSurvey = {
id: true,
createdAt: true,
@@ -114,19 +106,32 @@ export const selectSurvey = {
slug: true,
} satisfies Prisma.SurveySelect;
const getTriggerIds = (triggers: TSurvey["triggers"]): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
return triggers.map((trigger) => {
const actionClassId = trigger?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
if (!triggers) return;
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggers.forEach((trigger) => {
if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) {
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
// check if all the triggers are unique
const triggerIds = triggers.map((trigger) => trigger.actionClass.id);
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
@@ -137,36 +142,33 @@ export const handleTriggerUpdates = (
currentTriggers: TSurvey["triggers"],
actionClasses: ActionClass[]
) => {
if (!updatedTriggers) return {};
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id);
const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggers = updatedTriggers.filter(
(trigger) => !currentTriggerIds.includes(trigger.actionClass.id)
);
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggers = currentTriggers.filter(
(trigger) => !updatedTriggerIds.includes(trigger.actionClass.id)
);
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggers.length > 0) {
triggersUpdate.create = addedTriggers.map((trigger) => ({
actionClassId: trigger.actionClass.id,
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
if (deletedTriggers.length > 0) {
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggers.map((trigger) => trigger.actionClass.id),
in: deletedTriggerIds,
},
};
}
@@ -600,21 +602,16 @@ export const createSurvey = async (
);
try {
const { createdBy, ...restSurveyBody } = parsedSurveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
delete restSurveyBody.languages;
}
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const actionClasses = await getActionClasses(parsedEnvironmentId);
// @ts-expect-error
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...restSurveyBody,
// TODO: Create with attributeFilters
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
? // @ts-expect-error - triggers' createdAt and updatedAt are actually dates
handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
@@ -783,15 +780,13 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
};
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
const modifiedSurvey = {
...prismaSurvey,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return modifiedSurvey as TSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+1 -1
View File
@@ -52,7 +52,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
});
export const getUserByEmail = reactCache(async (email: string): Promise<TUser | null> => {
validateInputs([email, z.string().email()]);
validateInputs([email, z.email()]);
try {
const user = await prisma.user.findFirst({
@@ -1,4 +1,4 @@
import cuid2 from "@paralleldrive/cuid2";
import * as cuid2 from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { env } from "@/lib/env";
+2 -2
View File
@@ -1,10 +1,10 @@
import cuid2 from "@paralleldrive/cuid2";
import { createId } from "@paralleldrive/cuid2";
import { symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = cuid2.createId();
const cuid = createId();
if (!isEncrypted) {
return cuid;
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Antwortanzahl anzeigen",
"shown": "Angezeigt",
"size": "Größe",
"skip": "Überspringen",
"skipped": "Übersprungen",
"skips": "Übersprungen",
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
@@ -462,6 +463,7 @@
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
},
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_placeholder": "Welche spezifischen Aufgaben möchtest du automatisieren? Gibt es Tools oder Integrationen, die du gerne einbinden würdest?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
"placeholder": "Beschreibe den Workflow, den du generieren möchtest...",
"subheading": "Generiere deinen Workflow in Sekunden.",
"submit_button": "Details hinzufügen",
"thank_you_description": "Dein Input hilft uns dabei, das Workflows-Feature zu entwickeln, das du wirklich brauchst. Wir halten dich über unsere Fortschritte auf dem Laufenden.",
"thank_you_title": "Danke für dein Feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Show response count",
"shown": "Shown",
"size": "Size",
"skip": "Skip",
"skipped": "Skipped",
"skips": "Skips",
"some_files_failed_to_upload": "Some files failed to upload",
@@ -462,6 +463,7 @@
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you'd like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you'd want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
"placeholder": "Describe the workflow you want to generate...",
"subheading": "Generate your workflow in seconds.",
"submit_button": "Add details",
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We'll keep you posted on our progress.",
"thank_you_title": "Thank you for your feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Mostrar recuento de respuestas",
"shown": "Mostrado",
"size": "Tamaño",
"skip": "Omitir",
"skipped": "Omitido",
"skips": "Omisiones",
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
@@ -462,6 +463,7 @@
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Flujos de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Me sentí seguro mientras usaba el sistema.",
"usability_rating_description": "Mide la usabilidad percibida pidiendo a los usuarios que valoren su experiencia con tu producto mediante una encuesta estandarizada de 10 preguntas.",
"usability_score_name": "Puntuación de usabilidad del sistema (SUS)"
},
"workflows": {
"coming_soon_description": "¡Gracias por compartir tu idea de flujo de trabajo con nosotros! Actualmente estamos diseñando esta funcionalidad y tus comentarios nos ayudarán a construir exactamente lo que necesitas.",
"coming_soon_title": "¡Ya casi estamos!",
"follow_up_label": "¿Hay algo más que te gustaría añadir?",
"follow_up_placeholder": "¿Qué tareas específicas te gustaría automatizar? ¿Alguna herramienta o integración que quisieras incluir?",
"generate_button": "Generar flujo de trabajo",
"heading": "¿Qué flujo de trabajo quieres crear?",
"placeholder": "Describe el flujo de trabajo que quieres generar...",
"subheading": "Genera tu flujo de trabajo en segundos.",
"submit_button": "Añadir detalles",
"thank_you_description": "Tu aportación nos ayuda a construir la funcionalidad de flujos de trabajo que realmente necesitas. Te mantendremos informado sobre nuestro progreso.",
"thank_you_title": "¡Gracias por tus comentarios!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Afficher le nombre de réponses",
"shown": "Montré",
"size": "Taille",
"skip": "Ignorer",
"skipped": "Passé",
"skips": "Sauter",
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
@@ -462,6 +463,7 @@
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
},
"workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?",
"follow_up_placeholder": "Quelles tâches spécifiques aimeriez-vous automatiser? Des outils ou intégrations que vous souhaiteriez inclure?",
"generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?",
"placeholder": "Décrivez le workflow que vous souhaitez générer...",
"subheading": "Générez votre workflow en quelques secondes.",
"submit_button": "Ajouter des détails",
"thank_you_description": "Votre contribution nous aide à créer la fonctionnalité Workflows dont vous avez réellement besoin. Nous vous tiendrons informé de nos progrès.",
"thank_you_title": "Merci pour vos retours!"
}
}
+84 -69
View File
@@ -179,7 +179,7 @@
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
"count_selections": "{count, plural, one {{count} kijelölés} other {{count} kijelölés}}",
"count_selections": "{count, plural, one {{count} kiválasztás} other {{count} kiválasztás}}",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -190,7 +190,7 @@
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"date": "Dátum",
"days": "napok",
"days": "nap",
"default": "Alapértelmezett",
"delete": "Törlés",
"delete_what": "{deleteWhat} törlése",
@@ -223,7 +223,7 @@
"error": "Hiba",
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
"error_component_title": "Hiba az erőforrások betöltésekor",
"error_loading_data": "Hiba az adatok betöltése során",
"error_loading_data": "Hiba az adatok betöltésekor",
"error_rate_limit_description": "A kérések legnagyobb száma elérve. Próbálja meg később újra.",
"error_rate_limit_title": "A sebességkorlát elérve",
"expand_rows": "Sorok kinyitása",
@@ -245,11 +245,11 @@
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide_column": "Oszlop elrejtése",
"id": "ID",
"id": "Azonosító",
"image": "Kép",
"images": "Képek",
"import": "Importálás",
"impressions": "Benyomások",
"impressions": "Megtekintések",
"imprint": "Impresszum",
"in_progress": "Folyamatban",
"inactive_surveys": "Inaktív kérdőívek",
@@ -268,9 +268,9 @@
"license_expired": "A licenc lejárt",
"light_overlay": "Világos rávetítés",
"limits_reached": "Korlátok elérve",
"link": "Összekapcsolás",
"link_survey": "Kérdőív összekapcsolása",
"link_surveys": "Kérdőívek összekapcsolása",
"link": "Hivatkozás",
"link_survey": "Hivatkozás-kérdőív",
"link_surveys": "Hivatkozás-kérdőívek",
"load_more": "Továbbiak betöltése",
"loading": "Betöltés",
"logo": "Logó",
@@ -285,7 +285,7 @@
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
"mobile_overlay_surveys_look_good": "Ne aggódjon a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
"mobile_overlay_title": "Hoppá, apró képernyő észlelve!",
"months": "hónapok",
"months": "hónap",
"move_down": "Mozgatás le",
"move_up": "Mozgatás fel",
"multiple_languages": "Több nyelv",
@@ -323,7 +323,7 @@
"organization_settings": "Szervezet beállításai",
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
"other": "Egyéb",
"others": "Egyebek",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -393,6 +393,7 @@
"show_response_count": "Válaszok számának megjelenítése",
"shown": "Megjelenítve",
"size": "Méret",
"skip": "Kihagyás",
"skipped": "Kihagyva",
"skips": "Kihagyja",
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
@@ -460,8 +461,9 @@
"website_and_app_connection": "Webhely és alkalmazáskapcsolódás",
"website_app_survey": "Webhely és alkalmazás-kérdőív",
"website_survey": "Webhely kérdőív",
"weeks": "hetek",
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Munkafolyamatok",
"workspace_configuration": "Munkaterület beállítása",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
@@ -471,7 +473,7 @@
"workspace_not_found": "A munkaterület nem található",
"workspace_permission_not_found": "A munkaterület-jogosultság nem található",
"workspaces": "Munkaterületek",
"years": "évek",
"years": "év",
"you": "Ön",
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
@@ -643,12 +645,12 @@
"attribute_updated_successfully": "Az attribútum sikeresen frissítve",
"attribute_value": "Érték",
"attribute_value_placeholder": "Attribútum értéke",
"attributes_msg_attribute_limit_exceeded": "Nem sikerült létrehozni {count} új attribútumot, mivel az meghaladná a maximális {limit} attribútumosztály-korlátot. A meglévő attribútumok sikeresen frissítve lettek.",
"attributes_msg_attribute_type_validation_error": "{error} (a(z) '{key}' attribútum adattípusa: {dataType})",
"attributes_msg_email_already_exists": "Az e-mail cím már létezik ebben a környezetben, és nem lett frissítve.",
"attributes_msg_email_or_userid_required": "E-mail cím vagy felhasználói azonosító megadása kötelező. A meglévő értékek megmaradtak.",
"attributes_msg_new_attribute_created": "Új '{key}' attribútum létrehozva '{dataType}' típussal",
"attributes_msg_userid_already_exists": "A felhasználói azonosító már létezik ebben a környezetben, és nem lett frissítve.",
"attributes_msg_attribute_limit_exceeded": "Nem sikerült létrehozni {count} új attribútumot, mivel túllépte volna a(z) {limit} attribútumosztályból álló legnagyobb korlátot. A meglévő attribútumok sikeresen frissítve lettek.",
"attributes_msg_attribute_type_validation_error": "{error} (a(z) {key} attribútum a következő adattípussal rendelkezik: {dataType})",
"attributes_msg_email_already_exists": "Az e-mail-cím már létezik ennél a környezetnél, és nem lett frissítve.",
"attributes_msg_email_or_userid_required": "Vagy e-mail-cím, vagy felhasználó-azonosító szükséges. A meglévő értékek megmaradtak.",
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key} attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"contact_deleted_successfully": "A partner sikeresen törölve",
"contact_not_found": "Nem található ilyen partner",
"contacts_table_refresh": "Partnerek frissítése",
@@ -658,9 +660,9 @@
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
"custom_attributes": "Egyéni attribútumok",
"data_type": "Adattípus",
"data_type_cannot_be_changed": "Az adattípus létrehozás után nem módosítható",
"data_type_description": "Válaszd ki, hogyan legyen tárolva és szűrve ez az attribútum",
"date_value_required": "Dátum érték megadása kötelező. Használd a törlés gombot az attribútum eltávolításához, ha nem szeretnél dátumot megadni.",
"data_type_cannot_be_changed": "Az adattípust nem lehet megváltoztatni a létrehozás után",
"data_type_description": "Annak kiválasztása, hogy ezt az attribútumot hogyan kell tárolni és szűrni",
"date_value_required": "Dátumérték szükséges. Használja a törlés gombot az attribútum eltávolításához, ha nem szeretne dátumot beállítani.",
"delete_attribute_confirmation": "{value, plural, one {Ez törölni fogja a kiválasztott attribútumot. Az ehhez az attribútumhoz hozzárendelt összes partneradat el fog veszni.} other {Ez törölni fogja a kiválasztott attribútumokat. Az ezekhez az attribútumokhoz hozzárendelt összes partneradat el fog veszni.}}",
"delete_contact_confirmation": "Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ez a partner olyan válaszokkal rendelkezik, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.} other {Ez törölni fogja az ezekhez a partnerekhez tartozó összes kérdőívválaszt és partnerattribútumot. A partnerek adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ezek a partnerek olyan válaszokkal rendelkeznek, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.}}",
@@ -673,15 +675,15 @@
"edit_attributes_success": "A partner attribútumai sikeresen frissítve",
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV oszlopnév(nevek): {columns}. Az új attribútumokká váló oszlopnevek csak kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, és betűvel kell kezdődniük.",
"invalid_date_format": "Érvénytelen dátumformátum. Kérlek, adj meg egy érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Kérlek, adj meg egy érvényes számot.",
"no_activity_yet": "Még nincs aktivitás",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
"no_activity_yet": "Még nincs tevékenység",
"no_published_link_surveys_available": "Nem érhetők el közzétett hivatkozás-kérdőívek. Először tegyen közzé egy hivatkozás-kérdőívet.",
"no_published_surveys": "Nincsenek közzétett kérdőívek",
"no_responses_found": "Nem találhatók válaszok",
"not_provided": "Nincs megadva",
"number_value_required": "Szám érték megadása kötelező. Használd a törlés gombot az attribútum eltávolításához.",
"number_value_required": "Számérték szükséges. Használja a törlés gombot az attribútum eltávolításához.",
"personal_link_generated": "A személyes hivatkozás sikeresen előállítva",
"personal_link_generated_but_clipboard_failed": "A személyes hivatkozás előállítva, de nem sikerült a vágólapra másolni: {url}",
"personal_survey_link": "Személyes kérdőív-hivatkozás",
@@ -690,24 +692,24 @@
"search_contact": "Partner keresése",
"select_a_survey": "Kérdőív kiválasztása",
"select_attribute": "Attribútum kiválasztása",
"select_attribute_key": "Attribútum kulcs kiválasztása",
"select_attribute_key": "Attribútum kulcsának kiválasztása",
"survey_viewed": "Kérdőív megtekintve",
"survey_viewed_at": "Megtekintve",
"system_attributes": "Rendszer attribútumok",
"survey_viewed_at": "Megtekintve ekkor:",
"system_attributes": "Rendszerattribútumok",
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
"upload_contacts_error_attribute_type_mismatch": "A(z) \"{key}\" attribútum típusa \"{dataType}\", de a CSV érvénytelen értékeket tartalmaz: {values}",
"upload_contacts_error_duplicate_mappings": "Duplikált leképezések találhatók a következő attribútumokhoz: {attributes}",
"upload_contacts_error_file_too_large": "A fájl mérete meghaladja a maximális 800KB-os limitet",
"upload_contacts_error_generic": "Hiba történt a kapcsolatok feltöltése során. Kérjük, próbáld újra később.",
"upload_contacts_error_invalid_file_type": "Kérjük, tölts fel egy CSV fájlt",
"upload_contacts_error_no_valid_contacts": "A feltöltött CSV fájl nem tartalmaz érvényes kapcsolatokat, kérjük, nézd meg a minta CSV fájlt a helyes formátumhoz.",
"upload_contacts_modal_attribute_header": "Formbricks attribútum",
"upload_contacts_error_attribute_type_mismatch": "A(z) {key} attribútum {dataType}” típusként van megadva, de a CSV érvénytelen értékeket tartalmaz: {values}",
"upload_contacts_error_duplicate_mappings": "Kettőzött leképezések találhatók a következő attribútumoknál: {attributes}",
"upload_contacts_error_file_too_large": "A fájlméret túllépi a 800 KB-os legnagyobb méretet",
"upload_contacts_error_generic": "Hiba történt a partnerek feltöltése során. Próbálja meg később újra.",
"upload_contacts_error_invalid_file_type": "Töltsön fel egy CSV-fájlt",
"upload_contacts_error_no_valid_contacts": "A feltöltött CSV-fájl nem tartalmaz egyetlen érvényes partnert sem. Nézze meg a példa CSV-fájlt a helyes formátumért.",
"upload_contacts_modal_attribute_header": "Formbricks-attribútum",
"upload_contacts_modal_attributes_description": "A CSV-ben lévő oszlopok leképezése a Formbricksben lévő attribútumokra.",
"upload_contacts_modal_attributes_new": "Új attribútum",
"upload_contacts_modal_attributes_search_or_add": "Attribútum keresése vagy hozzáadása",
"upload_contacts_modal_attributes_title": "Attribútumok",
"upload_contacts_modal_csv_column_header": "CSV oszlop",
"upload_contacts_modal_csv_column_header": "CSV-oszlop",
"upload_contacts_modal_description": "CSV feltöltése a partnerek attribútumokkal együtt történő gyors importálásához",
"upload_contacts_modal_download_example_csv": "Példa CSV letöltése",
"upload_contacts_modal_duplicates_description": "Hogyan kell kezelnünk, ha egy partner már szerepel a partnerek között?",
@@ -765,11 +767,11 @@
"link_new_sheet": "Új táblázat összekapcsolása",
"no_integrations_yet": "A Google Táblázatok integrációi itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"reconnect_button": "Újrakapcsolódás",
"reconnect_button_description": "A Google Táblázatok kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő táblázathivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő táblázathivatkozások és adatok megmaradnak.",
"spreadsheet_permission_error": "Nincs jogosultsága a táblázat eléréséhez. Kérjük, győződjön meg arról, hogy a táblázat meg van osztva a Google-fiókjával, és írási jogosultsággal rendelkezik a táblázathoz.",
"reconnect_button_description": "A Google Táblázatok kapcsolata lejárt. Kapcsolódjon újra a válaszok szinkronizálásának folytatásához. A meglévő táblázatok hivatkozásai és adatai megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő táblázatok hivatkozásai és adatai megmaradnak.",
"spreadsheet_permission_error": "Nincs jogosultsága hozzáférni ehhez a táblázathoz. Győződjön meg arról, hogy a táblázat meg van-e osztva a Google-fiókjával, és rendelkezik-e írási hozzáféréssel a táblázathoz.",
"spreadsheet_url": "Táblázat URL-e",
"token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Kérjük, csatlakoztassa újra az integrációt."
"token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Csatlakoztassa újra az integrációt."
},
"include_created_at": "Létrehozva felvétele",
"include_hidden_fields": "Rejtett mezők felvétele",
@@ -898,35 +900,35 @@
"operator_ends_with": "ezzel végződik",
"operator_is_after": "ez után",
"operator_is_before": "ez előtt",
"operator_is_between": "között",
"operator_is_between": "ezek között",
"operator_is_newer_than": "újabb mint",
"operator_is_not_set": "nincs beállítva",
"operator_is_older_than": "régebbi mint",
"operator_is_same_day": "ugyanazon a napon",
"operator_is_set": "beállítva",
"operator_is_same_day": "ugyanaz a nap",
"operator_is_set": "be van állítva",
"operator_starts_with": "ezzel kezdődik",
"operator_title_contains": "Tartalmazza",
"operator_title_does_not_contain": "Nem tartalmazza",
"operator_title_ends_with": "Ezzel végződik",
"operator_title_equals": "Egyenlő",
"operator_title_greater_equal": "Nagyobb vagy egyenlő",
"operator_title_greater_equal": "Nagyobb mint vagy egyenlő",
"operator_title_greater_than": "Nagyobb mint",
"operator_title_is_after": "Ez után",
"operator_title_is_before": "Ez előtt",
"operator_title_is_between": "Között",
"operator_title_is_between": "Ezek között",
"operator_title_is_newer_than": "Újabb mint",
"operator_title_is_not_set": "Nincs beállítva",
"operator_title_is_older_than": "Régebbi mint",
"operator_title_is_same_day": "Ugyanazon a napon",
"operator_title_is_same_day": "Ugyanaz a nap",
"operator_title_is_set": "Beállítva",
"operator_title_less_equal": "Kisebb vagy egyenlő",
"operator_title_less_equal": "Kisebb mint vagy egyenlő",
"operator_title_less_than": "Kisebb mint",
"operator_title_not_equals": "Nem egyenlő",
"operator_title_not_equals": "Nem egyenlő ezzel",
"operator_title_starts_with": "Ezzel kezdődik",
"operator_title_user_is_in": "A felhasználó benne van",
"operator_title_user_is_not_in": "A felhasználó nincs benne",
"operator_user_is_in": "A felhasználó benne van",
"operator_user_is_not_in": "A felhasználó nincs benne",
"operator_title_user_is_in": "Felhasználó ebben",
"operator_title_user_is_not_in": "Felhasználó nem ebben",
"operator_user_is_in": "Felhasználó ebben",
"operator_user_is_not_in": "Felhasználó nem ebben",
"person_and_attributes": "Személy és attribútumok",
"phone": "Telefon",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Távolítsa el a szakaszt ezekből a kérdőívekből, hogy törölhesse azt.",
@@ -950,7 +952,7 @@
"unlock_segments_title": "Szakaszok feloldása egy magasabb csomaggal",
"user_targeting_is_currently_only_available_when": "A felhasználók megcélzása jelenleg csak akkor érhető el, ha",
"value_cannot_be_empty": "Az érték nem lehet üres.",
"value_must_be_a_number": "Az értékének számnak kell lennie.",
"value_must_be_a_number": "Az értéknek számnak kell lennie.",
"value_must_be_positive": "Az értéknek pozitív számnak kell lennie.",
"view_filters": "Szűrők megtekintése",
"where": "Ahol",
@@ -1084,7 +1086,7 @@
"email_customization_preview_email_heading": "Helló {userName}",
"email_customization_preview_email_text": "Ez egy e-mail előnézet, amely azt mutatja meg, hogy melyik logó fog megjelenni az e-mailekben.",
"error_deleting_organization_please_try_again": "Hiba a szervezet törlésekor. Próbálja meg újra.",
"from_your_organization": "{memberName} a szervezetből",
"from_your_organization": "{memberName} a szervezetéből",
"invitation_sent_once_more": "A meghívó még egyszer elküldve.",
"invite_deleted_successfully": "A meghívó sikeresen törölve",
"invite_expires_on": "A meghívó lejár ekkor: {date}",
@@ -1249,7 +1251,7 @@
"add_fallback_placeholder": "Helykitöltő hozzáadása annak megjelenítéshez, hogy nincs visszahívandó érték.",
"add_hidden_field_id": "Rejtett mezőazonosító hozzáadása",
"add_highlight_border": "Kiemelési szegély hozzáadása",
"add_highlight_border_description": "Csak a terméken belüli felmérésekre vonatkozik.",
"add_highlight_border_description": "Csak terméken belüli kérdőívekre vonatkozik.",
"add_logic": "Logika hozzáadása",
"add_none_of_the_above": "„A fentiek közül egyik sem” hozzáadása",
"add_option": "Lehetőség hozzáadása",
@@ -1620,7 +1622,7 @@
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"roundness": "Kerekesség",
"roundness_description": "Szabályozza a sarkok lekerekítését.",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"rows": "Sorok",
"save_and_close": "Mentés és bezárás",
@@ -1666,7 +1668,7 @@
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése",
"survey_styling": "Űrlap stílusának beállítása",
"survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
"target_block_not_found": "A célblokk nem található",
@@ -1811,7 +1813,7 @@
"this_response_is_in_progress": "Ez a válasz folyamatban van.",
"zip_post_code": "Irányítószám"
},
"search_by_survey_name": "Keresés kérőívnév alapján",
"search_by_survey_name": "Keresés kérdőívnév alapján",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítókat, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
@@ -1963,8 +1965,8 @@
"filtered_responses_csv": "Szűrt válaszok (CSV)",
"filtered_responses_excel": "Szűrt válaszok (Excel)",
"generating_qr_code": "QR-kód előállítása",
"impressions": "Benyomások",
"impressions_identified_only": "Csak az azonosított kapcsolatok megjelenítései láthatók",
"impressions": "Megtekintések",
"impressions_identified_only": "Csak azonosított partnerektől származó megtekintések megjelenítése",
"impressions_tooltip": "A kérdőív megtekintési alkalmainak száma.",
"in_app": {
"connection_description": "A kérdőív a webhelye azon felhasználóinak lesz megjelenítve, akik megfelelnek az alább felsorolt feltételeknek",
@@ -2007,7 +2009,7 @@
"last_quarter": "Elmúlt negyedév",
"last_year": "Elmúlt év",
"limit": "Korlát",
"no_identified_impressions": "Nincsenek megjelenítések azonosított kapcsolatoktól",
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
@@ -2173,12 +2175,12 @@
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
"advanced_styling_field_height": "Minimális magasság",
"advanced_styling_field_height": "Legkisebb magasság",
"advanced_styling_field_indicator_bg": "Jelző háttere",
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
"advanced_styling_field_input_height_description": "Szabályozza a beviteli mező minimális magasságát.",
"advanced_styling_field_input_height_description": "A beviteli mező legkisebb magasságát vezérli.",
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
@@ -2188,7 +2190,7 @@
"advanced_styling_field_option_bg": "Háttér",
"advanced_styling_field_option_bg_description": "Kitölti a választási lehetőség elemeit.",
"advanced_styling_field_option_border": "Szegély színe",
"advanced_styling_field_option_border_description": "A rádiógomb és jelölőnégyzet opciók körvonalát határozza meg.",
"advanced_styling_field_option_border_description": "Körberajzolja a rádiógomb és a jelölőnégyzet lehetőségeit.",
"advanced_styling_field_option_border_radius_description": "Lekerekíti a választási lehetőség sarkait.",
"advanced_styling_field_option_font_size_description": "Átméretezi a választási lehetőség címkéjének szövegét.",
"advanced_styling_field_option_label": "Címke színe",
@@ -2226,7 +2228,7 @@
"formbricks_branding_settings_description": "Nagyra értékeljük a támogatását, de megértjük, ha kikapcsolja.",
"formbricks_branding_shown": "A Formbricks márkajel megjelenik.",
"generate_theme_btn": "Előállítás",
"generate_theme_confirmation": "Szeretne hozzáillő színtémát létrehozni a márkajel színei alapján? Ez felülírja a jelenlegi színbeállításokat.",
"generate_theme_confirmation": "Szeretne hozzáillő színtémát előállítani a márkajel színei alapján? Ez felülírja a jelenlegi színbeállításokat.",
"generate_theme_header": "Előállítja a színtémát?",
"logo_removed_successfully": "A logó sikeresen eltávolítva",
"logo_settings_description": "Vállalati logo feltöltése a kérdőívek és hivatkozások előnézeteinek márkaépítéséhez.",
@@ -2243,7 +2245,7 @@
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
"styling_updated_successfully": "A stílus sikeresen frissítve",
"suggest_colors": "Színek ajánlása",
"suggested_colors_applied_please_save": "A javasolt színek sikeresen generálva. Nyomd meg a \"Mentés\" gombot a változtatások véglegesítéséhez.",
"suggested_colors_applied_please_save": "Az ajánlott színek sikeresen előállítva. Nyomja meg a Mentés gombot a változtatások mentéséhez.",
"theme": "Téma",
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
},
@@ -2305,7 +2307,7 @@
"mode": {
"formbricks_cx": "Formbricks CX",
"formbricks_cx_description": "Kérdőívek és jelentések annak megértéséhez, hogy mire van szükségük az ügyfeleknek.",
"formbricks_surveys": "Formbricks kérőívek",
"formbricks_surveys": "Formbricks kérdőívek",
"formbricks_surveys_description": "Többcélú kérdőíves platform web-, alkalmazás- és e-mail-kérdőívekhez.",
"what_are_you_here_for": "Miért van itt?"
},
@@ -3010,7 +3012,7 @@
"preview_survey_question_2_subheader": "Ez egy példa a leírásra.",
"preview_survey_question_open_text_headline": "Bármi egyéb, amit meg szeretne osztani?",
"preview_survey_question_open_text_placeholder": "Írja be ide a válaszát…",
"preview_survey_question_open_text_subheader": "A visszajelzése segít nekünk a fejlődésben.",
"preview_survey_question_open_text_subheader": "A visszajelzése segít nekünk fejlődni.",
"preview_survey_welcome_card_headline": "Üdvözöljük!",
"prioritize_features_description": "A felhasználóknak leginkább és legkevésbé szükséges funkciók azonosítása.",
"prioritize_features_name": "Funkciók rangsorolása",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Magabiztosnak éreztem magam a rendszer használata során.",
"usability_rating_description": "Az érzékelt használhatóság mérése arra kérve a felhasználókat, hogy értékeljék a termékkel kapcsolatos tapasztalataikat egy szabványosított, 10 kérdésből álló kérdőív használatával.",
"usability_score_name": "Rendszer-használhatósági pontszám (SUS)"
},
"workflows": {
"coming_soon_description": "Köszönjük, hogy megosztotta velünk a munkafolyamatra vonatkozó ötletét! Jelenleg a funkció kialakításán dolgozunk, és a visszajelzése segít nekünk abban, hogy pontosan azt alkossuk meg, amire szüksége van.",
"coming_soon_title": "Már majdnem kész vagyunk!",
"follow_up_label": "Van még bármi egyéb, amit hozzá szeretne fűzni?",
"follow_up_placeholder": "Milyen konkrét feladatokat szeretne automatizálni? Van olyan eszköz vagy integráció, amelyet szívesen látna a rendszerben?",
"generate_button": "Munkafolyamat előállítása",
"heading": "Milyen munkafolyamatot szeretne létrehozni?",
"placeholder": "Mutassa be az előállítani kívánt munkafolyamatot…",
"subheading": "Munkafolyamat előállítása másodpercek alatt.",
"submit_button": "Részletek hozzáadása",
"thank_you_description": "A visszajelzése segít nekünk abban, hogy olyan Munkafolyamatok funkciót alakítsunk ki, amelyre valóban szüksége van. Folyamatosan tájékoztatni fogjuk Önt a fejlesztés előrehaladásáról.",
"thank_you_title": "Köszönjük a visszajelzését!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "回答数を表示",
"shown": "表示済み",
"size": "サイズ",
"skip": "スキップ",
"skipped": "スキップ済み",
"skips": "スキップ数",
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
@@ -462,6 +463,7 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "システムを使っている間、自信がありました。",
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
"usability_score_name": "システムユーザビリティスコア(SUS)"
},
"workflows": {
"coming_soon_description": "ワークフローのアイデアを共有していただきありがとうございます!現在この機能を設計中で、あなたのフィードバックは私たちが必要とされる機能を構築するのに役立ちます。",
"coming_soon_title": "もうすぐ完成です!",
"follow_up_label": "他に追加したいことはありますか?",
"follow_up_placeholder": "どのような作業を自動化したいですか?含めたいツールや連携機能はありますか?",
"generate_button": "ワークフローを生成",
"heading": "どのようなワークフローを作成しますか?",
"placeholder": "生成したいワークフローを説明してください...",
"subheading": "数秒でワークフローを生成します。",
"submit_button": "詳細を追加",
"thank_you_description": "あなたの意見は、実際に必要とされるワークフロー機能の構築に役立ちます。進捗状況をお知らせします。",
"thank_you_title": "フィードバックありがとうございます!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Toon het aantal reacties",
"shown": "Getoond",
"size": "Maat",
"skip": "Overslaan",
"skipped": "Overgeslagen",
"skips": "Overslaan",
"some_files_failed_to_upload": "Sommige bestanden konden niet worden geüpload",
@@ -462,6 +463,7 @@
"website_survey": "Website-enquête",
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Ik voelde me zelfverzekerd tijdens het gebruik van het systeem.",
"usability_rating_description": "Meet de waargenomen bruikbaarheid door gebruikers te vragen hun ervaring met uw product te beoordelen met behulp van een gestandaardiseerde enquête met tien vragen.",
"usability_score_name": "Systeembruikbaarheidsscore (SUS)"
},
"workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat je wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je graag zou willen zien?",
"generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?",
"placeholder": "Beschrijf de workflow die je wilt genereren...",
"subheading": "Genereer je workflow in enkele seconden.",
"submit_button": "Voeg details toe",
"thank_you_description": "Jouw input helpt ons om de Workflows-functie te bouwen die je echt nodig hebt. We houden je op de hoogte van onze voortgang.",
"thank_you_title": "Bedankt voor je feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Mostrar contagem de respostas",
"shown": "mostrado",
"size": "Tamanho",
"skip": "Pular",
"skipped": "Pulou",
"skips": "Pula",
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
@@ -462,6 +463,7 @@
"website_survey": "Pesquisa de Site",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
},
"workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de adicionar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que você gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?",
"placeholder": "Descreva o fluxo de trabalho que você quer gerar...",
"subheading": "Gere seu fluxo de trabalho em segundos.",
"submit_button": "Adicionar detalhes",
"thank_you_description": "Sua contribuição nos ajuda a construir o recurso de Fluxos de trabalho que você realmente precisa. Manteremos você informado sobre nosso progresso.",
"thank_you_title": "Obrigado pelo seu feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Mostrar contagem de respostas",
"shown": "Mostrado",
"size": "Tamanho",
"skip": "Saltar",
"skipped": "Ignorado",
"skips": "Saltos",
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
@@ -462,6 +463,7 @@
"website_survey": "Inquérito do Website",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Obrigado por partilhar a sua ideia de fluxo de trabalho connosco! Estamos atualmente a desenhar esta funcionalidade e o seu feedback vai ajudar-nos a construir exatamente o que precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há mais alguma coisa que gostaria de acrescentar?",
"follow_up_placeholder": "Que tarefas específicas gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Que fluxo de trabalho quer criar?",
"placeholder": "Descreva o fluxo de trabalho que quer gerar...",
"subheading": "Gere o seu fluxo de trabalho em segundos.",
"submit_button": "Adicionar detalhes",
"thank_you_description": "A sua contribuição ajuda-nos a construir a funcionalidade de Fluxos de trabalho de que realmente precisa. Vamos mantê-lo informado sobre o nosso progresso.",
"thank_you_title": "Obrigado pelo seu feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Afișează numărul de răspunsuri",
"shown": "Afișat",
"size": "Mărime",
"skip": "Omite",
"skipped": "Sărit",
"skips": "Salturi",
"some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce",
@@ -462,6 +463,7 @@
"website_survey": "Chestionar despre site",
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace_configuration": "Configurare workspace",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "M-am simțit încrezător în timp ce utilizam sistemul.",
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
},
"workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
"follow_up_placeholder": "Ce sarcini specifice ai vrea să automatizezi? Există instrumente sau integrări pe care le-ai dori incluse?",
"generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?",
"placeholder": "Descrie workflow-ul pe care vrei să-l generezi...",
"subheading": "Generează-ți workflow-ul în câteva secunde.",
"submit_button": "Adaugă detalii",
"thank_you_description": "Contribuția ta ne ajută să construim funcția Workflows de care chiar ai nevoie. Te vom ține la curent cu progresul nostru.",
"thank_you_title": "Îți mulțumim pentru feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Показать количество ответов",
"shown": "Показано",
"size": "Размер",
"skip": "Пропустить",
"skipped": "Пропущено",
"skips": "Пропуски",
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
@@ -462,6 +463,7 @@
"website_survey": "Опрос сайта",
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Я чувствовал себя уверенно, используя систему.",
"usability_rating_description": "Оцените воспринимаемую удобство, попросив пользователей оценить свой опыт работы с вашим продуктом с помощью стандартизированного опроса из 10 вопросов.",
"usability_score_name": "Индекс удобства системы (SUS)"
},
"workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хочешь что-то ещё добавить?",
"follow_up_placeholder": "Какие задачи ты хочешь автоматизировать? Какие инструменты или интеграции тебе нужны?",
"generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?",
"placeholder": "Опиши воркфлоу, который хочешь сгенерировать...",
"subheading": "Сгенерируй свой воркфлоу за секунды.",
"submit_button": "Добавить детали",
"thank_you_description": "Твои идеи помогают нам создавать воркфлоу, которые действительно нужны. Мы будем держать тебя в курсе нашего прогресса.",
"thank_you_title": "Спасибо за твой отзыв!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "Visa antal svar",
"shown": "Visad",
"size": "Storlek",
"skip": "Hoppa över",
"skipped": "Överhoppad",
"skips": "Överhoppningar",
"some_files_failed_to_upload": "Några filer misslyckades att laddas upp",
@@ -462,6 +463,7 @@
"website_survey": "Webbplatsenkät",
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Arbetsflöden",
"workspace_configuration": "Arbetsytans konfiguration",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "Jag kände mig trygg när jag använde systemet.",
"usability_rating_description": "Mät upplevd användbarhet genom att be användare betygsätta sin upplevelse med din produkt med en standardiserad 10-frågors enkät.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Är det något mer du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Finns det några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?",
"placeholder": "Beskriv arbetsflödet du vill skapa...",
"subheading": "Skapa ditt arbetsflöde på några sekunder.",
"submit_button": "Lägg till detaljer",
"thank_you_description": "Din input hjälper oss att bygga arbetsflödesfunktionen du faktiskt behöver. Vi håller dig uppdaterad om hur det går.",
"thank_you_title": "Tack för din feedback!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "显示 响应 计数",
"shown": "显示",
"size": "尺寸",
"skip": "跳过",
"skipped": "跳过",
"skips": "跳过",
"some_files_failed_to_upload": "某些文件上传失败",
@@ -462,6 +463,7 @@
"website_survey": "网站 调查",
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "使用 系统 时 我 感到 自信。",
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
"usability_score_name": "系统 可用性 得分 ( SUS )"
},
"workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!",
"follow_up_label": "你还有其他想补充的吗?",
"follow_up_placeholder": "你希望自动化哪些具体任务?有没有想要集成的工具或服务?",
"generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?",
"placeholder": "描述一下你想生成的工作流……",
"subheading": "几秒钟生成你的工作流。",
"submit_button": "补充细节",
"thank_you_description": "你的意见帮助我们打造真正适合你的工作流功能。我们会及时向你汇报进展。",
"thank_you_title": "感谢你的反馈!"
}
}
+15
View File
@@ -393,6 +393,7 @@
"show_response_count": "顯示回應數",
"shown": "已顯示",
"size": "大小",
"skip": "略過",
"skipped": "已跳過",
"skips": "跳過次數",
"some_files_failed_to_upload": "部分檔案上傳失敗",
@@ -462,6 +463,7 @@
"website_survey": "網站問卷",
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
@@ -3259,5 +3261,18 @@
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
},
"workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!",
"follow_up_label": "還有什麼想補充的嗎?",
"follow_up_placeholder": "你想自動化哪些具體任務?有沒有想要整合的工具或服務?",
"generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?",
"placeholder": "請描述你想產生的工作流程⋯⋯",
"subheading": "幾秒鐘就能產生你的工作流程。",
"submit_button": "補充細節",
"thank_you_description": "你的意見幫助我們打造真正符合你需求的工作流程功能。我們會隨時更新進度給你。",
"thank_you_title": "感謝你的回饋!"
}
}
@@ -23,11 +23,17 @@ const ZCreateTagAction = z.object({
tagName: z.string(),
});
export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction).action(
export const createTagAction = authenticatedActionClient.inputSchema(ZCreateTagAction).action(
withAuditLogging(
"created",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateTagAction>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
@@ -65,103 +71,125 @@ const ZCreateTagToResponseAction = z.object({
tagId: ZId,
});
export const createTagToResponseAction = authenticatedActionClient.schema(ZCreateTagToResponseAction).action(
withAuditLogging(
"addedToResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
export const createTagToResponseAction = authenticatedActionClient
.inputSchema(ZCreateTagToResponseAction)
.action(
withAuditLogging(
"addedToResponse",
"tag",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateTagToResponseAction>;
}) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
)
);
const ZDeleteTagOnResponseAction = z.object({
responseId: ZId,
tagId: ZId,
});
export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDeleteTagOnResponseAction).action(
withAuditLogging(
"removedFromResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
export const deleteTagOnResponseAction = authenticatedActionClient
.inputSchema(ZDeleteTagOnResponseAction)
.action(
withAuditLogging(
"removedFromResponse",
"tag",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteTagOnResponseAction>;
}) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
const ZDeleteResponseAction = z.object({
responseId: ZId,
decrementQuotas: z.boolean().default(false),
decrementQuotas: z.boolean().prefault(false),
});
export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action(
export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDeleteResponseAction).action(
withAuditLogging(
"deleted",
"response",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteResponseAction>;
}) => {
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -192,7 +220,7 @@ const ZGetResponseAction = z.object({
});
export const getResponseAction = authenticatedActionClient
.schema(ZGetResponseAction)
.inputSchema(ZGetResponseAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -1,22 +1,23 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
main_database: z
.boolean()
.meta({
example: true,
})
.describe("Main database connection status - true if database is reachable and running"),
cache_database: z
.boolean()
.meta({
example: true,
})
.describe("Cache database connection status - true if cache database is reachable and running"),
})
.openapi({
.meta({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
})
.describe("Health check status for critical application dependencies");
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;
@@ -1,26 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZContactAttributeKeyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "contactAttributeKeyId",
description: "The ID of the contact attribute key",
.meta({
id: "contactAttributeKeyId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the contact attribute key");
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
name: true,
description: true,
}).openapi({
ref: "contactAttributeKeyUpdate",
}).meta({
id: "contactAttributeKeyUpdate",
description: "A contact attribute key to update. Key cannot be changed.",
});
@@ -17,7 +17,7 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
description: "Gets contact attribute keys from the database.",
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter.sourceType(),
query: ZGetContactAttributeKeysFilter,
},
responses: {
"200": {
@@ -17,7 +17,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetContactAttributeKeysFilter.sourceType(),
query: ZGetContactAttributeKeysFilter,
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
@@ -49,7 +49,7 @@ export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactAttributeKeyInput.sourceType(),
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
@@ -1,13 +1,10 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
extendZodWithOpenApi(z);
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({
environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"),
environmentId: z.cuid2().optional().describe("The environment ID to filter by"),
})
.refine(
(data) => {
@@ -37,15 +34,15 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
// Enforce safe identifier format for key
if (!isSafeIdentifier(data.key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
path: ["key"],
});
}
})
.openapi({
ref: "contactAttributeKeyInput",
.meta({
id: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
@@ -1,25 +1,21 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const ZResponseIdSchema = z
.string()
.cuid2()
.openapi({
ref: "responseId",
description: "The ID of the response",
.meta({
id: "responseId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the response");
export const ZResponseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).openapi({
ref: "responseUpdate",
}).meta({
id: "responseUpdate",
description: "A response to update.",
});
@@ -13,7 +13,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
summary: "Get responses",
description: "Gets responses from the database.",
requestParams: {
query: ZGetResponsesFilter.sourceType(),
query: ZGetResponsesFilter,
},
tags: ["Management API - Responses"],
responses: {
@@ -19,7 +19,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetResponsesFilter.sourceType(),
query: ZGetResponsesFilter,
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
@@ -3,7 +3,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZGetResponsesFilter = ZGetFilter.extend({
surveyId: z.string().cuid2().optional(),
surveyId: z.cuid2().optional(),
contactId: z.string().optional(),
}).refine(
(data) => {
@@ -23,7 +23,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
schema: makePartialSchema(
z.object({
data: z.object({
surveyUrl: z.string().url(),
surveyUrl: z.url(),
expiresAt: z
.string()
.nullable()
@@ -1,23 +1,18 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZContactLinkParams = z.object({
surveyId: z
.string()
.cuid2()
.openapi({
description: "The ID of the survey",
.meta({
param: { name: "surveyId", in: "path" },
}),
})
.describe("The ID of the survey"),
contactId: z
.string()
.cuid2()
.openapi({
description: "The ID of the contact",
.meta({
param: { name: "contactId", in: "path" },
}),
})
.describe("The ID of the contact"),
});
export const ZContactLinkQuery = z.object({
@@ -1,24 +1,19 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
extendZodWithOpenApi(z);
export const ZContactLinksBySegmentParams = z.object({
surveyId: z
.string()
.cuid2()
.openapi({
description: "The ID of the survey",
.meta({
param: { name: "surveyId", in: "path" },
}),
})
.describe("The ID of the survey"),
segmentId: z
.string()
.cuid2()
.openapi({
description: "The ID of the segment",
.meta({
param: { name: "segmentId", in: "path" },
}),
})
.describe("The ID of the segment"),
});
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
@@ -30,7 +25,7 @@ export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
.min(1)
.max(365)
.nullish()
.default(null)
.prefault(null)
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
attributeKeys: z
.string()
@@ -52,7 +47,7 @@ export type TContactWithAttributes = {
export const ZContactLinkResponse = z.object({
contactId: z.string().describe("The ID of the contact"),
surveyUrl: z.string().url().describe("Personalized survey link"),
surveyUrl: z.url().describe("Personalized survey link"),
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
});
@@ -1,16 +1,12 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const surveyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "surveyId",
description: "The ID of the survey",
.meta({
id: "surveyId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the survey");
@@ -1,15 +1,12 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
extendZodWithOpenApi(z);
export const ZGetSurveysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
limit: z.coerce.number().positive().min(1).max(100).optional().prefault(10),
skip: z.coerce.number().nonnegative().optional().prefault(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().prefault("createdAt"),
order: z.enum(["asc", "desc"]).optional().prefault("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
surveyType: z.enum(["link", "app"]).optional(),
@@ -23,7 +20,7 @@ export const ZGetSurveysFilter = z
return true;
},
{
message: "startDate must be before endDate",
error: "startDate must be before endDate",
}
);
@@ -69,8 +66,8 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
inlineTriggers: true,
displayPercentage: true,
})
.openapi({
ref: "surveyInput",
.meta({
id: "surveyInput",
description: "A survey input object for creating or updating surveys",
});
@@ -1,20 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
export const ZWebhookIdSchema = z
.string()
.cuid2()
.openapi({
ref: "webhookId",
description: "The ID of the webhook",
.meta({
id: "webhookId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the webhook");
export const ZWebhookUpdateSchema = ZWebhook.omit({
id: true,
@@ -22,7 +18,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
updatedAt: true,
environmentId: true,
secret: true,
}).openapi({
ref: "webhookUpdate",
}).meta({
id: "webhookUpdate",
description: "A webhook to update.",
});
@@ -13,7 +13,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
summary: "Get webhooks",
description: "Gets webhooks from the database.",
requestParams: {
query: ZGetWebhooksFilter.sourceType(),
query: ZGetWebhooksFilter,
},
tags: ["Management API - Webhooks"],
responses: {
@@ -11,7 +11,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetWebhooksFilter.sourceType(),
query: ZGetWebhooksFilter,
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
@@ -3,7 +3,7 @@ import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZGetWebhooksFilter = ZGetFilter.extend({
surveyIds: z.array(z.string().cuid2()).optional(),
surveyIds: z.array(z.cuid2()).optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
+1 -4
View File
@@ -1,6 +1,5 @@
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
import { createDocument } from "zod-openapi";
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
import { ZContact } from "@formbricks/database/zod/contact";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
@@ -27,8 +26,6 @@ import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import { contactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/lib/openapi";
extendZodWithOpenApi(z);
const document = createDocument({
openapi: "3.1.0",
info: {
@@ -14,7 +14,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
summary: "Get project teams",
description: "Gets projectTeams from the database.",
requestParams: {
query: ZGetProjectTeamsFilter.sourceType(),
query: ZGetProjectTeamsFilter,
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
@@ -24,7 +24,7 @@ export async function GET(request: Request, props: { params: Promise<{ organizat
return authenticatedApiClient({
request,
schemas: {
query: ZGetProjectTeamsFilter.sourceType(),
query: ZGetProjectTeamsFilter,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
@@ -3,8 +3,8 @@ import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZGetProjectTeamsFilter = ZGetFilter.extend({
teamId: z.string().cuid2().optional(),
projectId: z.string().cuid2().optional(),
teamId: z.cuid2().optional(),
projectId: z.cuid2().optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
@@ -28,8 +28,8 @@ export const ZProjectTeamInput = ZProjectTeam.pick({
export type TProjectTeamInput = z.infer<typeof ZProjectTeamInput>;
export const ZGetProjectTeamUpdateFilter = z.object({
teamId: z.string().cuid2(),
projectId: z.string().cuid2(),
teamId: z.cuid2(),
projectId: z.cuid2(),
});
export const ZProjectZTeamUpdateSchema = ZProjectTeam.pick({
@@ -1,20 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
extendZodWithOpenApi(z);
export const ZTeamIdSchema = z
.string()
.cuid2()
.openapi({
ref: "teamId",
description: "The ID of the team",
.meta({
id: "teamId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the team");
export const ZTeamUpdateSchema = ZTeam.omit({
id: true,
@@ -21,7 +21,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetTeamsFilter.sourceType(),
query: ZGetTeamsFilter,
},
tags: ["Organizations API - Teams"],
responses: {
@@ -16,7 +16,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ organ
authenticatedApiClient({
request,
schemas: {
query: ZGetTeamsFilter.sourceType(),
query: ZGetTeamsFilter,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
@@ -1,16 +1,12 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOrganizationIdSchema = z
.string()
.cuid2()
.openapi({
ref: "organizationId",
description: "The ID of the organization",
.meta({
id: "organizationId",
param: {
name: "organizationId",
in: "path",
},
});
})
.describe("The ID of the organization");
@@ -17,7 +17,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetUsersFilter.sourceType(),
query: ZGetUsersFilter,
},
tags: ["Organizations API - Users"],
responses: {
@@ -24,7 +24,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ organ
authenticatedApiClient({
request,
schemas: {
query: ZGetUsersFilter.sourceType(),
query: ZGetUsersFilter,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
+4 -4
View File
@@ -1,10 +1,10 @@
import { z } from "zod";
export const ZGetFilter = z.object({
limit: z.coerce.number().min(1).max(250).optional().default(50).describe("Number of items to return"),
skip: z.coerce.number().min(0).optional().default(0).describe("Number of items to skip"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt").describe("Sort by field"),
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
limit: z.coerce.number().min(1).max(250).optional().prefault(50).describe("Number of items to return"),
skip: z.coerce.number().min(0).optional().prefault(0).describe("Number of items to skip"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().prefault("createdAt").describe("Sort by field"),
order: z.enum(["asc", "desc"]).optional().prefault("desc").describe("Sort order"),
startDate: z.coerce.date().optional().describe("Start date"),
endDate: z.coerce.date().optional().describe("End date"),
filterDateField: z.enum(["createdAt", "updatedAt"]).optional().describe("Date field to filter by"),
@@ -1,6 +1,6 @@
import { z } from "zod";
export function responseWithMetaSchema<T extends z.ZodTypeAny>(contentSchema: T) {
export function responseWithMetaSchema<T extends z.ZodType>(contentSchema: T) {
return z.object({
data: z.array(contentSchema).optional(),
meta: z
+7 -2
View File
@@ -7,11 +7,16 @@ import { getUserByEmail } from "@/lib/user/service";
import { actionClient } from "@/lib/utils/action-client";
const ZCreateEmailTokenAction = z.object({
email: z.string().min(5).max(255).email({ message: "Invalid email" }),
email: z
.email({
error: "Invalid email",
})
.min(5)
.max(255),
});
export const createEmailTokenAction = actionClient
.schema(ZCreateEmailTokenAction)
.inputSchema(ZCreateEmailTokenAction)
.action(async ({ parsedInput }) => {
const user = await getUserByEmail(parsedInput.email);
if (!user) {
@@ -33,7 +33,7 @@ vi.mock("@/modules/email", () => ({
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
schema: vi.fn().mockReturnThis(),
inputSchema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
@@ -50,7 +50,7 @@ describe("forgotPasswordAction", () => {
};
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
});
afterEach(() => {
@@ -15,7 +15,7 @@ const ZForgotPasswordAction = z.object({
});
export const forgotPasswordAction = actionClient
.schema(ZForgotPasswordAction)
.inputSchema(ZForgotPasswordAction)
.action(async ({ parsedInput }) => {
await applyIPRateLimit(rateLimitConfigs.auth.forgotPassword);
@@ -13,7 +13,7 @@ import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
const ZForgotPasswordForm = z.object({
email: z.string().email(),
email: z.email(),
});
type TForgotPasswordForm = z.infer<typeof ZForgotPasswordForm>;
@@ -16,7 +16,7 @@ const ZResetPasswordAction = z.object({
password: ZUserPassword,
});
export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).action(
export const resetPasswordAction = actionClient.inputSchema(ZResetPasswordAction).action(
withAuditLogging(
"updated",
"user",
+25 -62
View File
@@ -2,87 +2,50 @@ import { Authenticator } from "@otplib/core";
import type { AuthenticatorOptions } from "@otplib/core/authenticator";
import { createDigest, createRandomBytes } from "@otplib/plugin-crypto";
import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { totpAuthenticatorCheck } from "./totp";
vi.mock("@otplib/core");
vi.mock("@otplib/plugin-crypto");
vi.mock("@otplib/plugin-thirty-two");
const createAuthenticator = (opts: Partial<AuthenticatorOptions> = {}) =>
new Authenticator({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
...opts,
});
describe("totpAuthenticatorCheck", () => {
const token = "123456";
const secret = "JBSWY3DPEHPK3PXP";
const opts: Partial<AuthenticatorOptions> = { window: [1, 0] };
const fixedEpoch = 1_700_000_000_000;
test("should check a TOTP token with a base32-encoded secret", () => {
const checkMock = vi.fn().mockReturnValue(true);
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: checkMock,
}));
const result = totpAuthenticatorCheck(token, secret, opts);
expect(Authenticator).toHaveBeenCalledWith({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
window: [1, 0],
});
expect(checkMock).toHaveBeenCalledWith(token, secret);
const token = createAuthenticator({ epoch: fixedEpoch }).generate(secret);
const result = totpAuthenticatorCheck(token, secret, { epoch: fixedEpoch, window: [1, 0] });
expect(result).toBe(true);
});
test("should use default window if none is provided", () => {
const checkMock = vi.fn().mockReturnValue(true);
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: checkMock,
}));
const result = totpAuthenticatorCheck(token, secret);
expect(Authenticator).toHaveBeenCalledWith({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
window: [1, 0],
});
expect(checkMock).toHaveBeenCalledWith(token, secret);
// Generate a token for one time-step in the past and verify it at current epoch.
// Default window is [1, 0], so previous-step tokens are accepted.
const token = createAuthenticator({ epoch: fixedEpoch }).generate(secret);
const result = totpAuthenticatorCheck(token, secret, { epoch: fixedEpoch + 30_000 });
expect(result).toBe(true);
});
test("should throw an error for invalid token format", () => {
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: () => {
throw new Error("Invalid token format");
},
}));
expect(() => {
totpAuthenticatorCheck("invalidToken", secret);
}).toThrow("Invalid token format");
test("should return false for invalid token format", () => {
const result = totpAuthenticatorCheck("invalidToken", secret);
expect(result).toBe(false);
});
test("should throw an error for invalid secret format", () => {
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: () => {
throw new Error("Invalid secret format");
},
}));
expect(() => {
totpAuthenticatorCheck(token, "invalidSecret");
}).toThrow("Invalid secret format");
test("should return false for invalid secret format", () => {
const token = createAuthenticator({ epoch: fixedEpoch }).generate(secret);
const result = totpAuthenticatorCheck(token, "invalidSecret", { epoch: fixedEpoch });
expect(result).toBe(false);
});
test("should return false if token verification fails", () => {
const checkMock = vi.fn().mockReturnValue(false);
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: checkMock,
}));
const result = totpAuthenticatorCheck(token, secret);
const token = createAuthenticator({ epoch: fixedEpoch }).generate(secret);
const result = totpAuthenticatorCheck(token, secret, { epoch: fixedEpoch + 60_000 });
expect(result).toBe(false);
});
});
@@ -21,11 +21,15 @@ import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/compon
import { PasswordInput } from "@/modules/ui/components/password-input";
const ZLoginForm = z.object({
email: z.string().email(),
email: z.email(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(128, { message: "Password must be 128 characters or less" }),
.min(8, {
error: "Password must be at least 8 characters long",
})
.max(128, {
error: "Password must be 128 characters or less",
}),
totpCode: z.string().optional(),
backupCode: z.string().optional(),
});
+1 -1
View File
@@ -177,7 +177,7 @@ async function handlePostUserCreation(
}
}
export const createUserAction = actionClient.schema(ZCreateUserAction).action(
export const createUserAction = actionClient.inputSchema(ZCreateUserAction).action(
withAuditLogging(
"created",
"user",
@@ -24,7 +24,7 @@ import { PasswordChecks } from "./password-checks";
const ZSignupInput = z.object({
name: ZUserName,
email: z.string().email(),
email: z.email(),
password: ZUserPassword,
});
@@ -35,7 +35,7 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
schema: vi.fn().mockReturnThis(),
inputSchema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
@@ -66,7 +66,7 @@ describe("resendVerificationEmailAction", () => {
};
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
});
afterEach(() => {
@@ -15,7 +15,7 @@ const ZResendVerificationEmailAction = z.object({
email: ZUserEmail,
});
export const resendVerificationEmailAction = actionClient.schema(ZResendVerificationEmailAction).action(
export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVerificationEmailAction).action(
withAuditLogging(
"verificationEmailSent",
"user",
@@ -8,7 +8,7 @@ import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUser, updateUser } from "@/modules/auth/lib/user";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
export const verifyEmailChangeAction = actionClient.schema(z.object({ token: z.string() })).action(
export const verifyEmailChangeAction = actionClient.inputSchema(z.object({ token: z.string() })).action(
withAuditLogging(
"updated",
"user",
@@ -2,9 +2,9 @@ import { z } from "zod";
export const ZRateLimitConfig = z.object({
/** Rate limit window in seconds */
interval: z.number().int().positive().describe("Rate limit window in seconds"),
interval: z.int().positive().describe("Rate limit window in seconds"),
/** Maximum allowed requests per interval */
allowedPerInterval: z.number().int().positive().describe("Maximum allowed requests per interval"),
allowedPerInterval: z.int().positive().describe("Maximum allowed requests per interval"),
/** Namespace for grouping rate limit per feature */
namespace: z.string().min(1).describe("Namespace for grouping rate limit per feature"),
});
@@ -73,12 +73,12 @@ export const ZAuditLogEventSchema = z.object({
type: ZAuditTarget,
}),
status: ZAuditStatus,
timestamp: z.string().datetime(),
timestamp: z.iso.datetime(),
organizationId: z.string(),
ipAddress: z.string().optional(), // Not using the .ip() here because if we don't enabled it we want to put UNKNOWN_DATA string, to keep the same pattern as the other fields
changes: z.record(z.any()).optional(),
changes: z.record(z.string(), z.any()).optional(),
eventId: z.string().optional(),
apiUrl: z.string().url().optional(),
apiUrl: z.url().optional(),
});
export type TAuditLogEvent = z.infer<typeof ZAuditLogEventSchema>;
+52 -38
View File
@@ -16,14 +16,20 @@ import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscri
const ZUpgradePlanAction = z.object({
environmentId: ZId,
priceLookupKey: z.nativeEnum(STRIPE_PRICE_LOOKUP_KEYS),
priceLookupKey: z.enum(STRIPE_PRICE_LOOKUP_KEYS),
});
export const upgradePlanAction = authenticatedActionClient.schema(ZUpgradePlanAction).action(
export const upgradePlanAction = authenticatedActionClient.inputSchema(ZUpgradePlanAction).action(
withAuditLogging(
"subscriptionUpdated",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpgradePlanAction>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
@@ -53,49 +59,57 @@ const ZManageSubscriptionAction = z.object({
environmentId: ZId,
});
export const manageSubscriptionAction = authenticatedActionClient.schema(ZManageSubscriptionAction).action(
withAuditLogging(
"subscriptionAccessed",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
export const manageSubscriptionAction = authenticatedActionClient
.inputSchema(ZManageSubscriptionAction)
.action(
withAuditLogging(
"subscriptionAccessed",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing.stripeCustomerId) {
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
}
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createCustomerPortalSession(
organization.billing.stripeCustomerId,
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
);
ctx.auditLoggingCtx.newObject = { portalSession: result };
return result;
}
if (!organization.billing.stripeCustomerId) {
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
}
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createCustomerPortalSession(
organization.billing.stripeCustomerId,
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
);
ctx.auditLoggingCtx.newObject = { portalSession: result };
return result;
}
)
);
)
);
const ZIsSubscriptionCancelledAction = z.object({
organizationId: ZId,
});
export const isSubscriptionCancelledAction = authenticatedActionClient
.schema(ZIsSubscriptionCancelledAction)
.inputSchema(ZIsSubscriptionCancelledAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -1,15 +1,12 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
import { getStripeClient } from "./stripe-client";
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
const stripe = getStripeClient();
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata?.organizationId)
throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id);
@@ -1,12 +1,7 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { STRIPE_API_VERSION, STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants";
import { env } from "@/lib/env";
import { STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
import { getStripeClient } from "./stripe-client";
export const createSubscription = async (
organizationId: string,
@@ -14,6 +9,7 @@ export const createSubscription = async (
priceLookupKey: STRIPE_PRICE_LOOKUP_KEYS
) => {
try {
const stripe = getStripeClient();
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Organization not found.");
@@ -8,7 +8,8 @@ import { getOrganization, updateOrganization } from "@/lib/organization/service"
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string;
const subscription = invoice.parent?.subscription_details?.subscription;
const subscriptionId = typeof subscription === "string" ? subscription : subscription?.id;
if (!subscriptionId) {
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
return { status: 400, message: "No subscription ID found in invoice" };
@@ -1,12 +1,6 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
import { getStripeClient } from "./stripe-client";
export const isSubscriptionCancelled = async (
organizationId: string
@@ -15,6 +9,7 @@ export const isSubscriptionCancelled = async (
date: Date | null;
}> => {
try {
const stripe = getStripeClient();
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Team not found.");
let isNewTeam =
@@ -34,9 +29,10 @@ export const isSubscriptionCancelled = async (
for (const subscription of subscriptions.data) {
if (subscription.cancel_at_period_end) {
const periodEndTimestamp = subscription.cancel_at ?? subscription.items.data[0]?.current_period_end;
return {
cancelled: true,
date: new Date(subscription.current_period_end * 1000),
date: periodEndTimestamp ? new Date(periodEndTimestamp * 1000) : null,
};
}
}
@@ -0,0 +1,21 @@
import Stripe from "stripe";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
export const getStripeClient = () => {
if (!env.STRIPE_SECRET_KEY) {
throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
}
return new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION,
});
};
export const getStripeWebhookSecret = () => {
if (!env.STRIPE_WEBHOOK_SECRET) {
throw new Error("Stripe webhook is not enabled; STRIPE_WEBHOOK_SECRET is not set.");
}
return env.STRIPE_WEBHOOK_SECRET;
};
@@ -1,20 +1,24 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
const webhookSecret: string = env.STRIPE_WEBHOOK_SECRET!;
import { getStripeClient, getStripeWebhookSecret } from "./stripe-client";
export const webhookHandler = async (requestBody: string, stripeSignature: string) => {
let stripe: Stripe;
let webhookSecret: string;
let event: Stripe.Event;
try {
stripe = getStripeClient();
webhookSecret = getStripeWebhookSecret();
} catch (err: unknown) {
logger.error(err, "Error getting Stripe client or webhook secret");
logger.warn("Stripe webhook skipped: Stripe is not configured");
return { status: 503, message: "Stripe webhook is not configured" };
}
try {
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
} catch (err) {
@@ -15,7 +15,7 @@ const ZGeneratePersonalSurveyLinkAction = z.object({
});
export const generatePersonalSurveyLinkAction = authenticatedActionClient
.schema(ZGeneratePersonalSurveyLinkAction)
.inputSchema(ZGeneratePersonalSurveyLinkAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
+49 -41
View File
@@ -23,12 +23,12 @@ import {
const ZGetContactsAction = z.object({
environmentId: ZId,
offset: z.number().int().nonnegative(),
offset: z.int().nonnegative(),
searchValue: z.string().optional(),
});
export const getContactsAction = authenticatedActionClient
.schema(ZGetContactsAction)
.inputSchema(ZGetContactsAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -53,7 +53,7 @@ const ZContactDeleteAction = z.object({
contactId: ZId,
});
export const deleteContactAction = authenticatedActionClient.schema(ZContactDeleteAction).action(
export const deleteContactAction = authenticatedActionClient.inputSchema(ZContactDeleteAction).action(
withAuditLogging(
"deleted",
"contact",
@@ -95,46 +95,54 @@ const ZCreateContactsFromCSV = z.object({
attributeMap: ZContactCSVAttributeMap,
});
export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCreateContactsFromCSV).action(
withAuditLogging(
"createdFromCSV",
"contact",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
export const createContactsFromCSVAction = authenticatedActionClient
.inputSchema(ZCreateContactsFromCSV)
.action(
withAuditLogging(
"createdFromCSV",
"contact",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createContactsFromCSV(
parsedInput.csvData,
parsedInput.environmentId,
parsedInput.duplicateContactsAction,
parsedInput.attributeMap
);
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createContactsFromCSV(
parsedInput.csvData,
parsedInput.environmentId,
parsedInput.duplicateContactsAction,
parsedInput.attributeMap
);
if ("contacts" in result) {
ctx.auditLoggingCtx.newObject = {
contacts: result.contacts,
};
if ("contacts" in result) {
ctx.auditLoggingCtx.newObject = {
contacts: result.contacts,
};
}
return result;
}
return result;
}
)
);
)
);
const ZUpdateContactAttributesAction = z.object({
contactId: ZId,
@@ -143,7 +151,7 @@ const ZUpdateContactAttributesAction = z.object({
export type TUpdateContactAttributesAction = z.infer<typeof ZUpdateContactAttributesAction>;
export const updateContactAttributesAction = authenticatedActionClient
.schema(ZUpdateContactAttributesAction)
.inputSchema(ZUpdateContactAttributesAction)
.action(
withAuditLogging(
"updated",
@@ -64,7 +64,7 @@ export const POST = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID v1 format detected"
);

Some files were not shown because too many files have changed in this diff Show More