Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
587b4a8924 Fix TypeError: Cannot read properties of null (reading 'finished')
Add null check for response before accessing finished property in PUT endpoint.
When getResponse returns null (response not found), return 404 instead of
attempting to access properties on null object.

Fixes FORMBRICKS-FN
2026-02-03 16:55:34 +00:00
44 changed files with 1090 additions and 1878 deletions

View File

@@ -32,20 +32,21 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 22.x
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
version: 9.15.9
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile
- name: Validate translation keys
run: |

View File

@@ -1,4 +1,20 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
};

View File

@@ -1,4 +1,4 @@
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
#
## step 1: Prune monorepo
@@ -20,7 +20,7 @@ FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate
RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -69,14 +69,20 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
#
## step 3: setup production runner
#
FROM base AS runner
# Update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -107,13 +113,15 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
# Copy prisma client packages
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
@@ -126,9 +134,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -138,8 +144,10 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/

View File

@@ -316,14 +316,6 @@ export const generateResponseTableColumns = (
},
};
const responseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "responseId",
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
cell: ({ row }) => {
return <IdBadge id={row.original.responseId} />;
},
};
const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota",
header: t("common.quota"),
@@ -384,24 +376,24 @@ export const generateResponseTableColumns = (
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
: [];
const metadataColumns = getMetadataColumnsData(t);
@@ -422,7 +414,6 @@ export const generateResponseTableColumns = (
const baseColumns = [
personColumn,
singleUseIdColumn,
responseIdColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,

View File

@@ -71,6 +71,12 @@ export const PUT = withV1ApiWrapper({
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),

View File

@@ -1,4 +1,4 @@
import { ImageResponse } from "next/og";
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {

View File

@@ -323,7 +323,6 @@ checksums:
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
common/response_id: 73375099cc976dc7203b8e27f5f709e0
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f
@@ -929,7 +928,7 @@ checksums:
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
environments/settings/general/invited_on: 83476ce4bcdfc3ccf524d1cd91b758a8
environments/settings/general/invites_failed: 180ffb8db417050227cc2b2ea74b7aae
environments/settings/general/leave_organization: e74132cb4a0dc98c41e61ea3b2dd268b
environments/settings/general/leave_organization_description: 2d0cd65e4e78a9b2835cf88c4de407fb

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
@@ -990,7 +989,7 @@
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invite_expires_on": "Einladung läuft ab am {date}",
"invited_on": "Eingeladen am {date}",
"invites_failed": "Einladungen fehlgeschlagen",
"leave_organization": "Organisation verlassen",
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
"restart": "Restart",
"role": "Role",
@@ -990,7 +989,7 @@
"from_your_organization": "from your organization",
"invitation_sent_once_more": "Invitation sent once more.",
"invite_deleted_successfully": "Invite deleted successfully",
"invite_expires_on": "Invite expires on {date}",
"invited_on": "Invited on {date}",
"invites_failed": "Invites failed",
"leave_organization": "Leave organization",
"leave_organization_description": "You wil leave this organization and loose access to all surveys and responses. You can only rejoin if you are invited again.",
@@ -1268,13 +1267,14 @@
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"display_type": "Display type",
"dropdown": "Dropdown",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey",
"display_type": "Display type",
"divide": "Divide /",
"does_not_contain": "Does not contain",
"does_not_end_with": "Does not end with",
@@ -1282,7 +1282,6 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"dropdown": "Dropdown",
"duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
@@ -1415,11 +1414,11 @@
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"list": "List",
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings",
"list": "List",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
@@ -3095,4 +3094,4 @@
"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)"
}
}
}

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"response": "Respuesta",
"response_id": "ID de respuesta",
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
@@ -990,7 +989,7 @@
"from_your_organization": "de tu organización",
"invitation_sent_once_more": "Invitación enviada una vez más.",
"invite_deleted_successfully": "Invitación eliminada correctamente",
"invite_expires_on": "La invitación expira el {date}",
"invited_on": "Invitado el {date}",
"invites_failed": "Las invitaciones fallaron",
"leave_organization": "Abandonar organización",
"leave_organization_description": "Abandonarás esta organización y perderás acceso a todas las encuestas y respuestas. Solo podrás volver a unirte si te invitan de nuevo.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"response": "Réponse",
"response_id": "ID de réponse",
"responses": "Réponses",
"restart": "Recommencer",
"role": "Rôle",
@@ -990,7 +989,7 @@
"from_your_organization": "de votre organisation",
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
"invite_expires_on": "L'invitation expire le {date}",
"invited_on": "Invité le {date}",
"invites_failed": "Invitations échouées",
"leave_organization": "Quitter l'organisation",
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",

View File

@@ -254,7 +254,7 @@
"label": "Címke",
"language": "Nyelv",
"learn_more": "Tudjon meg többet",
"license_expired": "A licenc lejárt",
"license_expired": "Licenc lejárt",
"light_overlay": "Világos rávetítés",
"limits_reached": "Korlátok elérve",
"link": "Összekapcsolás",
@@ -350,7 +350,6 @@
"request_trial_license": "Próbalicenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"response": "Válasz",
"response_id": "Válasz azonosító",
"responses": "Válaszok",
"restart": "Újraindítás",
"role": "Szerep",
@@ -463,7 +462,7 @@
"you_have_reached_your_monthly_miu_limit_of": "Elérte a havi MIU-korlátját ennek:",
"you_have_reached_your_monthly_response_limit_of": "Elérte a havi válaszkorlátját ennek:",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.",
"your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat."
"your_license_has_expired_please_renew": "A vállalati licenced lejárt. Kérjük, újítsd meg, hogy továbbra is használhasd a vállalati funkciókat."
},
"emails": {
"accept": "Elfogadás",
@@ -812,7 +811,7 @@
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
"webhook_updated_successfully": "A webhorog sikeresen frissítve.",
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
},
"website_or_app_integration_description": "A Formbricks integrálása a webhelyébe vagy alkalmazásába",
@@ -821,7 +820,7 @@
"segments": {
"add_filter_below": "Szűrő hozzáadása lent",
"add_your_first_filter_to_get_started": "Adja hozzá az első szűrőt a kezdéshez",
"cannot_delete_segment_used_in_surveys": "Nem tudja törölni ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
"cannot_delete_segment_used_in_surveys": "Nem tudja eltávolítani ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
"clone_and_edit_segment": "Szakasz klónozása és szerkesztése",
"create_group": "Csoport létrehozása",
"create_your_first_segment": "Hozza létre az első szakaszt a kezdéshez",
@@ -852,11 +851,11 @@
"reset_all_filters": "Összes szűrő visszaállítása",
"save_as_new_segment": "Mentés új szakaszként",
"save_your_filters_as_a_segment_to_use_it_in_other_surveys": "A szűrők mentése szakaszként más kérdőívekben való használathoz",
"segment_created_successfully": "A szakasz sikeresen létrehozva",
"segment_deleted_successfully": "A szakasz sikeresen törölve",
"segment_created_successfully": "A szakasz sikeresen létrehozva!",
"segment_deleted_successfully": "A szakasz sikeresen törölve!",
"segment_id": "Szakaszazonosító",
"segment_saved_successfully": "A szakasz sikeresen elmentve",
"segment_updated_successfully": "A szakasz sikeresen frissítve",
"segment_updated_successfully": "A szakasz sikeresen frissítve!",
"segments_help_you_target_users_with_same_characteristics_easily": "A szakaszok segítik a hasonló jellemzőkkel rendelkező felhasználók könnyű megcélzását",
"target_audience": "Célközönség",
"this_action_resets_all_filters_in_this_survey": "Ez a művelet visszaállítja az összes szűrőt ebben a kérdőívben.",
@@ -990,7 +989,7 @@
"from_your_organization": "a szervezetétő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: {date}",
"invited_on": "Meghívva ekkor: {date}",
"invites_failed": "A meghívás sikertelen",
"leave_organization": "Szervezet elhagyása",
"leave_organization_description": "Elhagyja ezt a szervezetet, és elveszíti az összes kérdőívhez és válaszhoz való hozzáférését. Csak akkor tud ismét csatlakozni, ha újra meghívják.",
@@ -1005,8 +1004,8 @@
"member_invited_successfully": "A tag sikeresen meghívva",
"once_its_gone_its_gone": "Ha egyszer eltűnt, akkor eltűnt.",
"only_org_owner_can_perform_action": "Csak a szervezet tulajdonosai férhetnek hozzá ehhez a beállításhoz.",
"organization_created_successfully": "A szervezet sikeresen létrehozva",
"organization_deleted_successfully": "A szervezet sikeresen törölve",
"organization_created_successfully": "A szervezet sikeresen létrehozva!",
"organization_deleted_successfully": "A szervezet sikeresen törölve.",
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
"organization_name": "Szervezet neve",
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
@@ -1106,8 +1105,8 @@
"select_member": "Tag kiválasztása",
"select_workspace": "Munkaterület kiválasztása",
"team_admin": "Csapatadminisztrátor",
"team_created_successfully": "A csapat sikeresen létrehozva",
"team_deleted_successfully": "A csapat sikeresen törölve",
"team_created_successfully": "A csapat sikeresen létrehozva.",
"team_deleted_successfully": "A csapat sikeresen törölve.",
"team_deletion_not_allowed": "Önnek nem engedélyezett ennek a csapatnak a törlése.",
"team_name": "Csapat neve",
"team_name_settings_title": "{teamName} beállításai",
@@ -1130,7 +1129,7 @@
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
"copy_survey_success": "A kérdőív sikeresen másolva",
"copy_survey_success": "A kérdőív sikeresen másolva!",
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Válassza ki a kérdőív alapértelmezett nyelvét:",
@@ -1274,7 +1273,7 @@
"disable_the_visibility_of_survey_progress": "A kérdőív előrehaladási folyamata láthatóságának letiltása.",
"display_an_estimate_of_completion_time_for_survey": "A kérdőív becsült kitöltési idejének megjelenítése",
"display_number_of_responses_for_survey": "A kérdőív válaszai számának megjelenítése",
"display_type": "Megjelenített típus",
"display_type": "Megjelenítési típus",
"divide": "Osztás /",
"does_not_contain": "Nem tartalmazza",
"does_not_end_with": "Nem ezzel végződik",
@@ -1282,7 +1281,7 @@
"does_not_include_all_of": "Nem tartalmazza ezekből az összeset",
"does_not_include_one_of": "Nem tartalmazza ezek egyikét",
"does_not_start_with": "Nem ezzel kezdődik",
"dropdown": "Legördülő",
"dropdown": "Legördülő menü",
"duplicate_block": "Blokk kettőzése",
"duplicate_question": "Kérdés kettőzése",
"edit_link": "Hivatkozás szerkesztése",
@@ -1546,7 +1545,7 @@
"send_survey_to_audience_who_match": "Kérdőív küldése az erre illeszkedő közönségnek…",
"send_your_respondents_to_a_page_of_your_choice": "A válaszadók küldése a választási lehetőség oldalára.",
"set_the_global_placement_in_the_look_feel_settings": "A globális elhelyezés beállítása a megjelenítési beállításokban.",
"settings_saved_successfully": "A beállítások sikeresen elmentve",
"settings_saved_successfully": "A beállítások sikeresen elmentve.",
"seven_points": "7 pont",
"show_block_settings": "Blokkbeállítások megjelenítése",
"show_button": "Gomb megjelenítése",
@@ -1713,7 +1712,7 @@
"person_attributes": "A személy jellemzői a beküldés időpontjában",
"phone": "Telefon",
"respondent_skipped_questions": "A válaszadó kihagyta ezeket a kérdéseket.",
"response_deleted_successfully": "A válasz sikeresen törölve",
"response_deleted_successfully": "A válasz sikeresen törölve.",
"single_use_id": "Egyszer használatos azonosító",
"source": "Forrás",
"state_region": "Állam vagy régió",
@@ -1725,7 +1724,7 @@
"search_by_survey_name": "Keresés kérőí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.",
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítót, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
"custom_single_use_id_title": "Bármilyen értéket beállíthat egyszer használatos azonosítóként az URL-ben.",
"custom_start_point": "Egyéni kezdési pont",
"data_prefilling": "Adatok előre kitöltése",
@@ -1950,8 +1949,8 @@
"your_survey_is_public": "A kérdőíve nyilvános",
"youre_not_plugged_in_yet": "Még nincs csatlakoztatva!"
},
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
"survey_deleted_successfully": "A kérdőív sikeresen törölve!",
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve.",
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
"templates": {
"all_channels": "Összes csatorna",
@@ -2014,8 +2013,8 @@
"custom_scripts_updated_successfully": "Az egyéni parancsfájlok sikeres frissítve",
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
"delete_workspace": "Munkaterület törlése",
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} projektet? Ezt a műveletet nem lehet visszavonni.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} projekt törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
"only_owners_or_managers_can_delete_workspaces": "Csak tulajdonosok vagy kezelők törölhetnek munkaterületeket",
@@ -2098,9 +2097,9 @@
"search_tags": "Címkék keresése…",
"tag": "Címke",
"tag_already_exists": "A címke már létezik",
"tag_deleted": "A címke sikeresen törölve",
"tag_updated": "A címke sikeresen frissítve",
"tags_merged": "A címkék sikeresen egyesítve"
"tag_deleted": "Címke törölve",
"tag_updated": "Címke frissítve",
"tags_merged": "Címkék egyesítve"
},
"teams": {
"manage_teams": "Csapatok kezelése",
@@ -2295,7 +2294,7 @@
"career_development_survey_question_5_choice_5": "Üzemeltetés",
"career_development_survey_question_5_choice_6": "Egyéb",
"career_development_survey_question_5_headline": "Milyen funkcióban dolgozik?",
"career_development_survey_question_5_subheader": "Válassza ki a következő lehetőségek egyikét:",
"career_development_survey_question_5_subheader": "Válassza a következők egyikét",
"career_development_survey_question_6_choice_1": "Egyéni közreműködő",
"career_development_survey_question_6_choice_2": "Igazgató",
"career_development_survey_question_6_choice_3": "Vezető igazgató",
@@ -2303,7 +2302,7 @@
"career_development_survey_question_6_choice_5": "Igazgató",
"career_development_survey_question_6_choice_6": "Egyéb",
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
"career_development_survey_question_6_subheader": "Válassza a következők egyikét",
"cess_survey_name": "Ügyfél-erőfeszítési pontszám kérdőív",
"cess_survey_question_1_headline": "A(z) $[projectName] megkönnyíti számomra a [CÉL HOZZÁADÁSA] tevékenységet",
"cess_survey_question_1_lower_label": "Egyáltalán nem értek egyet",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "トライアルライセンスをリクエスト",
"reset_to_default": "デフォルトにリセット",
"response": "回答",
"response_id": "回答ID",
"responses": "回答",
"restart": "再開",
"role": "役割",
@@ -990,7 +989,7 @@
"from_your_organization": "あなたの組織から",
"invitation_sent_once_more": "招待状を再度送信しました。",
"invite_deleted_successfully": "招待を正常に削除しました",
"invite_expires_on": "招待は{date}に期限切れ",
"invited_on": "{date}に招待",
"invites_failed": "招待に失敗しました",
"leave_organization": "組織を離れる",
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"response": "Antwoord",
"response_id": "Antwoord-ID",
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
@@ -990,7 +989,7 @@
"from_your_organization": "vanuit uw organisatie",
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
"invite_expires_on": "Uitnodiging verloopt op {date}",
"invited_on": "Uitgenodigd op {date}",
"invites_failed": "Uitnodigingen zijn mislukt",
"leave_organization": "Verlaat de organisatie",
"leave_organization_description": "U verlaat deze organisatie en verliest de toegang tot alle enquêtes en reacties. Je kunt alleen weer meedoen als je opnieuw wordt uitgenodigd.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"response": "Resposta",
"response_id": "ID da resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Rolê",
@@ -990,7 +989,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invite_expires_on": "O convite expira em {date}",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"response": "Resposta",
"response_id": "ID de resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Função",
@@ -990,7 +989,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
"invite_expires_on": "O convite expira em {date}",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Vai sair desta organização e perder o acesso a todos os inquéritos e respostas. Só pode voltar a juntar-se se for convidado novamente.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Solicitați o licență de încercare",
"reset_to_default": "Revino la implicit",
"response": "Răspuns",
"response_id": "ID răspuns",
"responses": "Răspunsuri",
"restart": "Repornește",
"role": "Rolul",
@@ -990,7 +989,7 @@
"from_your_organization": "din organizația ta",
"invitation_sent_once_more": "Invitație trimisă din nou.",
"invite_deleted_successfully": "Invitație ștearsă cu succes",
"invite_expires_on": "Invitația expiră pe {date}",
"invited_on": "Invitat pe {date}",
"invites_failed": "Invitații eșuate",
"leave_organization": "Părăsește organizația",
"leave_organization_description": "Vei părăsi această organizație și vei pierde accesul la toate sondajele și răspunsurile. Poți să te alături din nou doar dacă ești invitat.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Запросить пробную лицензию",
"reset_to_default": "Сбросить по умолчанию",
"response": "Ответ",
"response_id": "ID ответа",
"responses": "Ответы",
"restart": "Перезапустить",
"role": "Роль",
@@ -990,7 +989,7 @@
"from_your_organization": "из вашей организации",
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
"invite_deleted_successfully": "Приглашение успешно удалено",
"invite_expires_on": "Приглашение истекает {date}",
"invited_on": "Приглашён {date}",
"invites_failed": "Не удалось отправить приглашения",
"leave_organization": "Покинуть организацию",
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "Begär provlicens",
"reset_to_default": "Återställ till standard",
"response": "Svar",
"response_id": "Svar-ID",
"responses": "Svar",
"restart": "Starta om",
"role": "Roll",
@@ -990,7 +989,7 @@
"from_your_organization": "från din organisation",
"invitation_sent_once_more": "Inbjudan skickad igen.",
"invite_deleted_successfully": "Inbjudan borttagen",
"invite_expires_on": "Inbjudan går ut den {date}",
"invited_on": "Inbjuden den {date}",
"invites_failed": "Inbjudningar misslyckades",
"leave_organization": "Lämna organisation",
"leave_organization_description": "Du kommer att lämna denna organisation och förlora åtkomst till alla enkäter och svar. Du kan endast återansluta om du blir inbjuden igen.",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "申请试用许可证",
"reset_to_default": "重置为 默认",
"response": "响应",
"response_id": "响应 ID",
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
@@ -990,7 +989,7 @@
"from_your_organization": "来自你的组织",
"invitation_sent_once_more": "再次发送邀请。",
"invite_deleted_successfully": "邀请 删除 成功",
"invite_expires_on": "邀请将于 {date} 过期",
"invited_on": "邀于 {date}",
"invites_failed": "邀请失败",
"leave_organization": "离开 组织",
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",

View File

@@ -350,7 +350,6 @@
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
"response_id": "回應 ID",
"responses": "回應",
"restart": "重新開始",
"role": "角色",
@@ -990,7 +989,7 @@
"from_your_organization": "來自您的組織",
"invitation_sent_once_more": "已再次發送邀請。",
"invite_deleted_successfully": "邀請已成功刪除",
"invite_expires_on": "邀請於 '{'date'}' 過期",
"invited_on": "邀請於 '{'date'}'",
"invites_failed": "邀請失敗",
"leave_organization": "離開組織",
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useTransition } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { TI18nString } from "@formbricks/types/i18n";
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
@@ -74,8 +74,6 @@ export function LocalizedEditor({
[id, isInvalid, localSurvey.languages, value]
);
const [, startTransition] = useTransition();
return (
<div className="relative w-full">
<Editor
@@ -111,45 +109,44 @@ export function LocalizedEditor({
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
}
// Check if the elements still exists before updating
const currentElement = elements[elementIdx];
startTransition(() => {
// if this is a card, we wanna check if the card exists in the localSurvey
if (isCard) {
const isWelcomeCard = elementIdx === -1;
const isEndingCard = elementIdx >= elements.length;
// if this is a card, we wanna check if the card exists in the localSurvey
if (isCard) {
const isWelcomeCard = elementIdx === -1;
const isEndingCard = elementIdx >= elements.length;
// For ending cards, check if the field exists before updating
if (isEndingCard) {
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
// If the field doesn't exist on the ending card, don't create it
if (!ending || ending[id] === undefined) {
return;
}
}
// For welcome cards, check if it exists
if (isWelcomeCard && !localSurvey.welcomeCard) {
// For ending cards, check if the field exists before updating
if (isEndingCard) {
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
// If the field doesn't exist on the ending card, don't create it
if (!ending || ending[id] === undefined) {
return;
}
}
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement({ [id]: translatedContent });
// For welcome cards, check if it exists
if (isWelcomeCard && !localSurvey.welcomeCard) {
return;
}
// Check if the field exists on the element (not just if it's not undefined)
if (currentElement && id in currentElement && currentElement[id] !== undefined) {
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement(elementIdx, { [id]: translatedContent });
}
});
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement({ [id]: translatedContent });
return;
}
// Check if the field exists on the element (not just if it's not undefined)
if (currentElement && id in currentElement && currentElement[id] !== undefined) {
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement(elementIdx, { [id]: translatedContent });
}
}}
localSurvey={localSurvey}
elementId={elementId}

View File

@@ -36,7 +36,6 @@ import {
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import {
Select,
SelectContent,
@@ -188,16 +187,14 @@ export const TeamSettingsModal = ({
const currentMemberId = watchMembers[index]?.userId;
return orgMembers
.filter((om) => !selectedMemberIds.includes(om?.id) || om?.id === currentMemberId)
.map((om) => ({ label: om?.name, value: om?.id }))
.sort((a, b) => a.label.localeCompare(b.label));
.map((om) => ({ label: om?.name, value: om?.id }));
};
const getProjectOptionsForIndex = (index: number) => {
const currentProjectId = watchProjects[index]?.projectId;
return orgProjects
.filter((op) => !selectedProjectIds.includes(op?.id) || op?.id === currentProjectId)
.map((op) => ({ label: op?.name, value: op?.id }))
.sort((a, b) => a.label.localeCompare(b.label));
.map((op) => ({ label: op?.name, value: op?.id }));
};
const handleMemberSelectionChange = (index: number, userId: string) => {
@@ -281,21 +278,29 @@ export const TeamSettingsModal = ({
return (
<FormItem className="flex-1">
<InputCombobox
id={`member-${index}-select`}
options={memberOpts}
value={member.userId || null}
onChangeValue={(val) => {
const userId = val as string;
field.onChange(userId);
handleMemberSelectionChange(index, userId);
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={!!isSelectDisabled}
comboboxClasses="max-w-full"
searchPlaceholder={t(
"environments.settings.teams.select_member"
)}
/>
disabled={isSelectDisabled}
value={member.userId}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_member")}
/>
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
@@ -421,19 +426,26 @@ export const TeamSettingsModal = ({
return (
<FormItem className="flex-1">
<InputCombobox
id={`project-${index}-select`}
options={projectOpts}
value={project.projectId || null}
onChangeValue={(val) => {
field.onChange(val as string);
}}
disabled={!!isSelectDisabled}
comboboxClasses="max-w-full"
searchPlaceholder={t(
"environments.settings.teams.select_workspace"
)}
/>
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={isSelectDisabled}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_workspace")}
/>
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}

View File

@@ -2,7 +2,6 @@
import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
@@ -24,7 +23,7 @@ import {
getMembershipsByUserId,
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -58,57 +57,30 @@ const ZCreateInviteTokenAction = z.object({
inviteId: ZUuid,
});
export const createInviteTokenAction = authenticatedActionClient.schema(ZCreateInviteTokenAction).action(
withAuditLogging(
"updated",
"invite",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateInviteTokenAction>;
}) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
export const createInviteTokenAction = authenticatedActionClient
.schema(ZCreateInviteTokenAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
// Get old expiresAt for audit logging before update
const oldInvite = await prisma.invite.findUnique({
where: { id: parsedInput.inviteId },
select: { email: true, expiresAt: true },
});
if (!oldInvite) {
throw new ValidationError("Invite not found");
}
// Refresh the invitation expiration
const updatedInvite = await refreshInviteExpiration(parsedInput.inviteId);
// Set audit context
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { expiresAt: oldInvite.expiresAt };
ctx.auditLoggingCtx.newObject = { expiresAt: updatedInvite.expiresAt };
const inviteToken = createInviteToken(parsedInput.inviteId, updatedInvite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
const invite = await getInvite(parsedInput.inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
)
);
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
});
const ZDeleteMembershipAction = z.object({
userId: ZId,
@@ -219,7 +191,6 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
invite?.creator?.name ?? "",
updatedInvite.name ?? ""
);
return updatedInvite;
}
)

View File

@@ -80,7 +80,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (createInviteTokenResponse?.data) {
setShareInviteToken(createInviteTokenResponse.data.inviteToken);
setShowShareInviteModal(true);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(createInviteTokenResponse);
toast.error(errorMessage);
@@ -100,7 +99,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
});
if (resendInviteResponse?.data) {
toast.success(t("environments.settings.general.invitation_sent_once_more"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(resendInviteResponse);
toast.error(errorMessage);

View File

@@ -47,8 +47,8 @@ export const MembersInfo = ({
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: getFormattedDateTimeString(member.expiresAt),
tooltipContent={`${t("environments.settings.general.invited_on", {
date: getFormattedDateTimeString(member.createdAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>

View File

@@ -9,14 +9,7 @@ import {
} from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { TInvitee } from "../types/invites";
import {
deleteInvite,
getInvite,
getInvitesByOrganizationId,
inviteUser,
refreshInviteExpiration,
resendInvite,
} from "./invite";
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -53,129 +46,32 @@ const mockInvite: Invite = {
teamIds: [],
};
describe("refreshInviteExpiration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("updates expiresAt to approximately 7 days from now", async () => {
const now = Date.now();
const expectedExpiresAt = new Date(now + 1000 * 60 * 60 * 24 * 7);
vi.mocked(prisma.invite.update).mockResolvedValue({
...mockInvite,
expiresAt: expectedExpiresAt,
});
const result = await refreshInviteExpiration("invite-1");
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-1" },
data: {
expiresAt: expect.any(Date),
},
});
expect(result.expiresAt.getTime()).toBeGreaterThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 - 1000);
expect(result.expiresAt.getTime()).toBeLessThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 + 1000);
});
test("throws ResourceNotFoundError if invite not found (P2025)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on other prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if non-prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.update).mockRejectedValue(error);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow("db");
});
test("returns full invite object with all fields", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
const result = await refreshInviteExpiration("invite-1");
expect(result).toEqual(updatedInvite);
expect(result.id).toBe("invite-1");
expect(result.email).toBe("test@example.com");
expect(result.name).toBe("Test User");
});
});
describe("resendInvite", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns email and name after updating expiration", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
test("returns email and name if invite exists", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue({ ...mockInvite, creator: {} });
vi.mocked(prisma.invite.update).mockResolvedValue({ ...mockInvite, organizationId: "org-1" });
const result = await resendInvite("invite-1");
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-1" },
data: {
expiresAt: expect.any(Date),
},
});
});
test("calls refreshInviteExpiration helper", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
await resendInvite("invite-1");
expect(prisma.invite.update).toHaveBeenCalledTimes(1);
});
test("throws ResourceNotFoundError if invite not found", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on other prisma errors", async () => {
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
await expect(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if non-prisma error", async () => {
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.update).mockRejectedValue(error);
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
await expect(resendInvite("invite-1")).rejects.toThrow("db");
});
});

View File

@@ -13,21 +13,44 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
try {
const updatedInvite = await prisma.invite.update({
where: { id: inviteId },
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
name: true,
creator: true,
},
});
return updatedInvite;
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
const updatedInvite = await prisma.invite.update({
where: {
id: inviteId,
},
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
},
select: {
id: true,
email: true,
name: true,
organizationId: true,
},
});
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
throw new ResourceNotFoundError("Invite", inviteId);
}
throw new DatabaseError(error.message);
}
@@ -35,16 +58,6 @@ export const refreshInviteExpiration = async (inviteId: string): Promise<Invite>
}
};
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
// Refresh expiration and return the updated invite (single query)
const updatedInvite = await refreshInviteExpiration(inviteId);
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
};
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> => {
validateInputs([organizationId, z.string()], [page, z.number().optional()]);

View File

@@ -54,7 +54,7 @@ import {
} from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { isEndingCardValid, isWelcomeCardValid, validateElement } from "../lib/validation";
import { isEndingCardValid, isWelcomeCardValid, validateSurveyElementsInBatch } from "../lib/validation";
interface ElementsViewProps {
localSurvey: TSurvey;
@@ -211,6 +211,35 @@ export const ElementsView = ({
};
};
useEffect(() => {
if (!invalidElements) return;
let updatedInvalidElements: string[] = [...invalidElements];
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
if (!updatedInvalidElements.includes("start")) {
updatedInvalidElements = [...updatedInvalidElements, "start"];
}
} else {
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== "start");
}
// Check thank you card
localSurvey.endings.forEach((ending) => {
if (!isEndingCardValid(ending, surveyLanguages)) {
if (!updatedInvalidElements.includes(ending.id)) {
updatedInvalidElements = [...updatedInvalidElements, ending.id];
}
} else {
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== ending.id);
}
});
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
setInvalidElements(updatedInvalidElements);
}
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidElements, setInvalidElements]);
const updateElement = (elementIdx: number, updatedAttributes: any) => {
// Get element ID from current elements array (for validation)
const element = elements[elementIdx];
@@ -221,6 +250,7 @@ export const ElementsView = ({
// Track side effects that need to happen after state update
let newActiveElementId: string | null = null;
let invalidElementsUpdate: string[] | null = null;
// Use functional update to ensure we work with the latest state
setLocalSurvey((prevSurvey) => {
@@ -266,6 +296,13 @@ export const ElementsView = ({
const initialElementId = elementId;
updatedSurvey = handleElementLogicChange(updatedSurvey, initialElementId, elementLevelAttributes.id);
// Track side effects to apply after state update
if (invalidElements?.includes(initialElementId)) {
invalidElementsUpdate = invalidElements.map((id) =>
id === initialElementId ? elementLevelAttributes.id : id
);
}
// Track new active element ID
newActiveElementId = elementLevelAttributes.id;
@@ -307,6 +344,9 @@ export const ElementsView = ({
});
// Apply side effects after state update is queued
if (invalidElementsUpdate) {
setInvalidElements(invalidElementsUpdate);
}
if (newActiveElementId) {
setActiveElementId(newActiveElementId);
}
@@ -724,67 +764,23 @@ export const ElementsView = ({
setLocalSurvey(result.data);
};
// Validate survey when changes are made to languages or elements
// using set for O(1) lookup
useEffect(
() => {
if (!invalidElements) return;
//useEffect to validate survey when changes are made to languages
useEffect(() => {
if (!invalidElements) return;
let updatedInvalidElements: string[] = invalidElements;
// Validate each element
elements.forEach((element) => {
updatedInvalidElements = validateSurveyElementsInBatch(
element,
updatedInvalidElements,
surveyLanguages
);
});
const currentInvalidSet = new Set(invalidElements);
let hasChanges = false;
// Validate each element
elements.forEach((element) => {
const isValid = validateElement(element, surveyLanguages);
if (isValid) {
if (currentInvalidSet.has(element.id)) {
currentInvalidSet.delete(element.id);
hasChanges = true;
}
} else if (!currentInvalidSet.has(element.id)) {
currentInvalidSet.add(element.id);
hasChanges = true;
}
});
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
if (!currentInvalidSet.has("start")) {
currentInvalidSet.add("start");
hasChanges = true;
}
} else if (currentInvalidSet.has("start")) {
currentInvalidSet.delete("start");
hasChanges = true;
}
// Check thank you card
localSurvey.endings.forEach((ending) => {
if (!isEndingCardValid(ending, surveyLanguages)) {
if (!currentInvalidSet.has(ending.id)) {
currentInvalidSet.add(ending.id);
hasChanges = true;
}
} else if (currentInvalidSet.has(ending.id)) {
currentInvalidSet.delete(ending.id);
hasChanges = true;
}
});
if (hasChanges) {
setInvalidElements(Array.from(currentInvalidSet));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
elements,
surveyLanguages,
invalidElements,
setInvalidElements,
localSurvey.welcomeCard,
localSurvey.endings,
]
);
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
setInvalidElements(updatedInvalidElements);
}
}, [elements, surveyLanguages, invalidElements, setInvalidElements]);
useEffect(() => {
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
@@ -795,7 +791,7 @@ export const ElementsView = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
}, [activeElementId, setActiveElementId]);
const sensors = useSensors(
useSensor(PointerSensor, {

View File

@@ -86,7 +86,6 @@ export const SurveyEditor = ({
const [activeElementId, setActiveElementId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
const [invalidElements, setInvalidElements] = useState<string[] | null>(null);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
const [localProject, setLocalProject] = useState<Project>(project);

View File

@@ -64,7 +64,6 @@ export interface InputComboboxProps {
showCheckIcon?: boolean;
comboboxClasses?: string;
emptyDropdownText?: string;
disabled?: boolean;
}
// Helper to flatten all options and their children
@@ -88,7 +87,6 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
showCheckIcon = false,
comboboxClasses,
emptyDropdownText,
disabled = false,
}) => {
const { t } = useTranslation();
const resolvedSearchPlaceholder = searchPlaceholder ?? t("common.search");
@@ -203,7 +201,6 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
<div
className={cn(
"group/icon flex max-w-[440px] overflow-hidden rounded-md border border-slate-300 hover:border-slate-400",
disabled && "pointer-events-none opacity-50",
comboboxClasses
)}>
{withInput && inputType !== "dropdown" && (
@@ -216,7 +213,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
/>
)}
<DropdownMenu open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild className="z-10">
<div
id={id}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/web",
"version": "0.0.0",
"packageManager": "pnpm@10.28.2",
"packageManager": "pnpm@9.15.9",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next coverage",
@@ -19,9 +19,9 @@
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
},
"dependencies": {
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@boxyhq/saml-jackson": "1.52.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",

View File

@@ -80,8 +80,7 @@
"xm-and-surveys/surveys/general-features/email-followups",
"xm-and-surveys/surveys/general-features/quota-management",
"xm-and-surveys/surveys/general-features/spam-protection",
"xm-and-surveys/surveys/general-features/tags",
"xm-and-surveys/surveys/general-features/validation-rules"
"xm-and-surveys/surveys/general-features/tags"
]
},
{
@@ -162,7 +161,6 @@
"xm-and-surveys/core-features/integrations/activepieces",
"xm-and-surveys/core-features/integrations/airtable",
"xm-and-surveys/core-features/integrations/google-sheets",
"xm-and-surveys/core-features/integrations/hubspot",
"xm-and-surveys/core-features/integrations/make",
"xm-and-surveys/core-features/integrations/n8n",
"xm-and-surveys/core-features/integrations/notion",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1,183 +0,0 @@
---
title: "HubSpot"
description: "Learn how to integrate Formbricks with HubSpot to automatically create or update contacts when survey responses are submitted."
---
<Note>
Formbricks doesn't have a native HubSpot integration yet. This guide shows you how to connect Formbricks with HubSpot using automation platforms (Make.com, n8n) or custom webhooks.
</Note>
## Prerequisites
Before setting up the integration, you'll need:
1. **A Formbricks account** with at least one survey that collects email addresses
2. **A HubSpot account** with API access
3. **HubSpot API credentials** (see authentication options below)
4. **Automation service** like Make, n8n, or ActivePieces (for no-code methods)
### HubSpot Authentication Options
HubSpot offers two main ways to authenticate API requests:
#### Option A: OAuth 2.0 via Public App (Recommended)
OAuth is the recommended approach for production integrations. When using Make.com or n8n, they handle OAuth authentication for you through their built-in HubSpot connectors.
#### Option B: Legacy Private Apps (Simple Setup)
For custom webhook handlers, you can use a Legacy Private App which provides a static access token. While marked as "legacy," these apps remain fully supported by HubSpot.
To create a Legacy Private App:
<Steps>
<Step title="Open HubSpot Settings">
Go to your HubSpot account **Settings** and navigate to **Integrations** → **Private Apps**.
</Step>
<Step title="Create the App">
Click **Create a private app** and give it a name (e.g., "Formbricks Integration").
</Step>
<Step title="Configure Scopes">
Under **Scopes**, add `crm.objects.contacts.write` and `crm.objects.contacts.read`.
</Step>
<Step title="Get Your Access Token">
Click **Create app** and copy the access token.
</Step>
</Steps>
<Note>
For more information on HubSpot's authentication options, see the [HubSpot Authentication Overview](https://developers.hubspot.com/docs/guides/apps/authentication/intro-to-auth).
</Note>
---
## Method 1: Using Make.com (Recommended for No-Code)
<Note>
Before starting, ensure your survey has clear `questionId` values set. You can only update these before publishing. If your survey is already published, duplicate it and update the question IDs in the copy.
</Note>
<Steps>
<Step title="Set Up Your Survey">
Make sure your survey has meaningful `questionId` values for each question. This makes mapping responses to HubSpot fields easier.
![Update Question ID](/images/xm-and-surveys/core-features/integrations/make/update-question-id.webp)
</Step>
<Step title="Create a New Make.com Scenario">
Go to [Make.com](https://make.com) and create a new scenario. Search for **Formbricks** and select it as your trigger, then choose **Response Finished** as the trigger event.
![Search Formbricks](/images/xm-and-surveys/core-features/integrations/make/search-formbricks.webp)
</Step>
<Step title="Connect Formbricks to Make">
Click **Create a webhook**, enter your Formbricks API Host (default: `https://app.formbricks.com`), add your Formbricks API Key (see [API Key Setup](/api-reference/rest-api#how-to-generate-an-api-key)), and select the survey you want to connect.
![Enter API Key](/images/xm-and-surveys/core-features/integrations/make/enter-api-key-and-host.webp)
</Step>
<Step title="Add the HubSpot Module">
Click the **+** button after the Formbricks trigger, search for **HubSpot**, choose **Create or Update a Contact** as the action, and connect your HubSpot account.
</Step>
<Step title="Map Formbricks Fields to HubSpot">
Map the Formbricks response fields to HubSpot contact properties:
| HubSpot Field | Formbricks Field |
| ------------- | ---------------- |
| Email | `data.email` (your email question ID) |
| First Name | `data.firstName` (if collected) |
| Last Name | `data.lastName` (if collected) |
| Custom Property | Any other `data.*` field |
You can also map metadata: `meta.country`, `meta.userAgent.browser`, `survey.title`.
</Step>
<Step title="Test and Activate">
Submit a test response to your Formbricks survey, verify the contact appears in HubSpot, and turn on your Make scenario.
</Step>
</Steps>
---
## Method 2: Using n8n (Self-Hosted Option)
<Note>
The Formbricks n8n node is available as a community node. Install it via **Settings** → **Community Nodes** → install `@formbricks/n8n-nodes-formbricks`.
</Note>
<Steps>
<Step title="Set Up the Formbricks Trigger">
Create a new workflow in n8n, add the **Formbricks** trigger node, connect it with your Formbricks API Key and host, select **Response Finished** as the event, and choose your survey.
![Add Formbricks Trigger](/images/xm-and-surveys/core-features/integrations/n8n/add-formbricks-trigger.webp)
</Step>
<Step title="Add the HubSpot Node">
Add a new node and search for **HubSpot**, select **Create/Update Contact** as the operation, and connect your HubSpot account (n8n supports both OAuth and access token authentication).
</Step>
<Step title="Configure Field Mapping">
In the HubSpot node, map the fields:
```
Email: {{ $json.data.email }}
First Name: {{ $json.data.firstName }}
Last Name: {{ $json.data.lastName }}
```
For custom HubSpot properties, use the **Additional Fields** section to add mappings like `survey_source`, `response_id`, and `submission_date`.
</Step>
<Step title="Test Your Workflow">
Click **Listen for event** in the Formbricks trigger, submit a test survey response, verify the data flows through to HubSpot, and activate your workflow.
</Step>
</Steps>
---
## Method 3: Using Webhooks (Custom Integration)
For maximum flexibility, you can use Formbricks webhooks with a custom endpoint that calls the HubSpot API directly. This approach is ideal for developers who want full control.
<Note>
This method requires a HubSpot access token. You can use a Legacy Private App token (simplest) or implement OAuth 2.0 for production applications.
</Note>
<Steps>
<Step title="Create a Formbricks Webhook">
Go to **Configuration** → **Integrations** in Formbricks, click **Manage Webhooks** → **Add Webhook**, enter your endpoint URL, select **Response Finished** as the trigger, and choose the surveys to monitor.
![Integrations Tab](/images/xm-and-surveys/core-features/integrations/webhooks/integrations-tab.webp)
</Step>
<Step title="Build Your Webhook Handler">
Your webhook handler needs to:
- **Receive the Formbricks webhook** - Accept POST requests with the survey response payload
- **Extract contact data** - Parse the email and other fields from `data.data` (keyed by your `questionId` values)
- **Call the HubSpot API** - Use the [HubSpot Contacts API](https://developers.hubspot.com/docs/api/crm/contacts) to create or update contacts
- **Handle duplicates** - HubSpot returns a 409 error if a contact with that email exists; search and update instead
You can deploy using serverless functions (Vercel, AWS Lambda, Cloudflare Workers), traditional servers, or low-code platforms. For webhook signature verification, see the [Webhooks documentation](/xm-and-surveys/core-features/integrations/webhooks).
</Step>
<Step title="Deploy and Test">
Deploy your webhook handler to a publicly accessible URL, add the URL to your Formbricks webhook configuration, submit a test survey response, and verify the contact appears in HubSpot.
</Step>
</Steps>
---
## Troubleshooting
### Contact Not Created in HubSpot
1. **Check the email field**: Ensure your survey has an email question and you're mapping the correct `questionId`
2. **Verify API token**: Make sure your HubSpot access token has the required scopes (`crm.objects.contacts.write` and `crm.objects.contacts.read`)
3. **Check for duplicates**: HubSpot returns a 409 error if a contact with that email already exists
### Webhook Not Triggering
1. Verify the webhook URL is publicly accessible
2. Check that **Response Finished** trigger is selected
3. Ensure the survey is linked to the webhook
### Testing Your Integration
1. Use a unique test email for each test
2. Check HubSpot's **Contacts** page after submitting a response
3. Review your webhook handler logs for errors
---
Still struggling or something not working as expected? [Join our GitHub Discussions](https://github.com/formbricks/formbricks/discussions) and we're happy to help!

View File

@@ -15,8 +15,6 @@ At Formbricks, we understand the importance of integrating with third-party appl
* [Google Sheets](/xm-and-surveys/core-features/integrations/google-sheets): Automatically send responses to a Google Sheet of your choice.
* [HubSpot](/xm-and-surveys/core-features/integrations/hubspot): Create or update HubSpot contacts automatically when survey responses are submitted.
* [Make](/xm-and-surveys/core-features/integrations/make): Leverage Make's powerful automation capabilities to automate your workflows.
* [n8n](/xm-and-surveys/core-features/integrations/n8n)(Open Source): Automate workflows with n8n's no-code automation tool

View File

@@ -1,185 +0,0 @@
---
title: "Validation Rules"
description: "Validation rules help you ensure that respondents provide data in the correct format and within expected constraints"
icon: "check-double"
---
By adding validation rules to your questions, you can improve data quality, reduce errors, and create a better survey experience.
![Validation Rules Editor](/images/xm-and-surveys/core-features/validation-rules/editor.webp)
## How Validation Rules Work
Validation rules are evaluated when a respondent submits their answer. If the answer doesn't meet the validation criteria, an error message is displayed and the respondent must correct their input before proceeding.
You can combine multiple validation rules using **All are true** or **Any is true** logic:
- **All are true**: All rules must pass for the response to be valid
- **Any is true**: At least one rule must pass for the response to be valid
## Available Validation Rules by Question Type
### Free Text Questions
Free text questions support different validation rules based on the input type:
#### Text Input Type
| Rule | Description | Example |
|------|-------------|---------|
| At least (characters) | Requires at least N characters | At least 10 characters for detailed feedback |
| At most (characters) | Limits response to N characters | At most 500 characters for short answers |
| Matches Regex Pattern | Matches a regular expression pattern | Custom format validation |
| Is | Exact match required | Must equal "CONFIRM" |
| Is not | Must not match the value | Cannot be "N/A" |
| Contains | Must include the substring | Must contain "@company.com" |
| Does not contain | Must not include the substring | Cannot contain profanity |
#### Email Input Type
Email input automatically validates email format. Additional rules available:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
#### URL Input Type
URL input automatically validates URL format. Additional rules available:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
#### Phone Input Type
Phone input automatically validates phone number format. Additional rules available:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
#### Number Input Type
| Rule | Description | Example |
|------|-------------|---------|
| At least | Number must be at least N | Age must be at least 18 |
| At most | Number cannot exceed N | Quantity at most 100 |
| Is | Number must equal N | Quantity is 1 |
| Is not | Number must not equal N | Cannot be 0 |
### Multiple Choice (Multi-Select) Questions
| Rule | Description | Example |
|------|-------------|---------|
| At least (options selected) | Require at least N selections | At least 2 options selected |
| At most (options selected) | Limit to N selections | At most 3 options selected |
### Picture Selection Questions
| Rule | Description | Example |
|------|-------------|---------|
| At least (options selected) | Require at least N pictures | At least 1 design selected |
| At most (options selected) | Limit to N pictures | At most 2 favorites selected |
### Date Questions
| Rule | Description | Example |
|------|-------------|---------|
| Is later than | Date must be after specified date | Must be after today |
| Is earlier than | Date must be before specified date | Must be before Dec 31, 2025 |
| Is between | Date must be within range | Between Jan 1 and Dec 31 |
| Is not between | Date must be outside range | Cannot be during holidays |
<Note>
Date values should be specified in YYYY-MM-DD format (e.g., 2025-01-15).
</Note>
### Matrix Questions
| Rule | Description | Example |
|------|-------------|---------|
| Minimum rows answered | Require at least N rows to be answered | Answer at least 3 rows |
| Answer all rows | All rows must have a selection | Complete the entire matrix |
### Ranking Questions
| Rule | Description | Example |
|------|-------------|---------|
| Minimum options ranked | Require at least N items to be ranked | Rank your top 3 |
| Rank all options | All options must be ranked | Rank all 5 items |
### File Upload Questions
| Rule | Description | Example |
|------|-------------|---------|
| File extension is | Only allow specific file types | Only .pdf, .docx allowed |
| File extension is not | Block specific file types | No .exe files |
<Note>
File size limits are configured separately in the question settings using the "Maximum file size" option.
</Note>
### Address Questions
Each address field (Address Line 1, Address Line 2, City, State, ZIP, Country) can have its own validation rules:
- At least, At most (characters)
- Matches Regex Pattern
- Is, Is not
- Contains, Does not contain
### Contact Info Questions
Each contact field can have specific validation rules:
**First Name, Last Name, Company**:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
**Email**: Automatically validates email format, plus text rules above
**Phone**: Automatically validates phone format, plus text rules above
## Adding Validation Rules
<Steps>
<Step title="Open the Question Settings">
Click on the question you want to validate to open its settings panel.
</Step>
<Step title="Navigate to Validation Rules">
Scroll down to find the "Validation Rules" section and click to expand it.
</Step>
<Step title="Add a Rule">
Click the "Add rule" button to add a new validation rule.
</Step>
<Step title="Configure the Rule">
Select the rule type from the dropdown and enter the required value (if applicable).
</Step>
<Step title="Set Logic (Optional)">
If you have multiple rules, choose whether they should be combined with "All are true" or "Any is true" logic.
</Step>
<Step title="Save Your Survey">
Click "Save" to apply the validation rules to your survey.
</Step>
</Steps>
## Error Messages
Formbricks automatically generates user-friendly error messages based on your validation rules. Error messages are displayed below the input field when validation fails.
Example error messages:
- "Must be at least 10 characters"
- "Must be a valid email address"
- "Please select at least 2 options"
- "Date must be after 2025-01-01"
## Multi-Language Support
Validation rules work with multi-language surveys. Error messages are automatically displayed in the respondent's selected language.
## Combining Multiple Rules
When using multiple validation rules:
**All are true**: Use when all conditions must be met.
- Example: Text must be at least 10 characters AND contain "@email.com"
**Any is true**: Use when any condition is acceptable.
- Example: Date is earlier than 2025-01-01 OR is later than 2025-12-31

View File

@@ -73,9 +73,9 @@
]
},
"engines": {
"node": ">=20.0.0"
"node": ">=16.0.0"
},
"packageManager": "pnpm@10.28.2",
"packageManager": "pnpm@9.15.9",
"nextBundleAnalysis": {
"budget": 358400,
"budgetPercentIncreaseRed": 20,
@@ -90,13 +90,10 @@
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26",
"systeminformation": "5.27.14",
"qs": ">=6.14.1",
"preact": ">=10.26.10",
"fast-xml-parser": ">=5.3.4",
"diff": ">=8.0.3"
"qs": ">=6.14.1"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates | preact (Dependabot #247) - awaiting next-auth update | fast-xml-parser (Dependabot #270) - awaiting @boxyhq/saml-jackson update | diff (Dependabot #269) - awaiting @microsoft/api-extractor update"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates"
},
"patchedDependencies": {
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"

View File

@@ -10,6 +10,7 @@
"eslint-config-next": "15.3.2",
"eslint-config-prettier": "10.1.5",
"eslint-config-turbo": "2.5.3",
"eslint-plugin-i18n-json": "4.0.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",

View File

@@ -170,7 +170,7 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr
// Run Prisma migrate
// throws when migrate deploy fails
await execAsync(`prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
await execAsync(`pnpm prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
logger.info(`Successfully applied schema migration: ${migration.name}`);
} catch (err) {
logger.error(err, `Schema migration ${migration.name} failed`);

View File

@@ -37,9 +37,9 @@
"author": "Formbricks <hola@formbricks.com>",
"dependencies": {
"@formbricks/logger": "workspace:*",
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0"
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -21,8 +21,8 @@
"respondents_will_not_see_this_card": "A válaszadók nem fogják látni ezt a kártyát",
"retry": "Újrapróbálkozás",
"retrying": "Újrapróbálkozás…",
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"select_option": "Válassz egy lehetőséget",
"select_options": "Válassz lehetőségeket",
"sending_responses": "Válaszok küldése…",
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
@@ -48,7 +48,7 @@
},
"invalid_device_error": {
"message": "Tiltsa le a szemét elleni védekezést a kérdőív beállításaiban, hogy tovább használhassa ezt az eszközt.",
"title": "Ez az eszköz nem támogatja a szemét elleni védekezést."
"title": "Ez az eszköz nem támogatja a spam elleni védelmet."
},
"invalid_format": "Adjon meg egy érvényes formátumot",
"is_between": "Válasszon egy dátumot {startDate} és {endDate} között",
@@ -71,7 +71,7 @@
"please_fill_out_this_field": "Töltse ki ezt a mezőt",
"recaptcha_error": {
"message": "A válaszát nem sikerült elküldeni, mert automatizált tevékenységként lett megjelölve. Ha lélegzik, akkor próbálja meg újra.",
"title": "Nem tudtuk ellenőrizni, hogy Ön ember-e."
"title": "Nem tudtuk ellenőrizni, hogy ember vagy."
},
"value_must_contain": "Az értéknek tartalmaznia kell ezt: {value}",
"value_must_equal": "Az értéknek egyenlőnek kell lennie ezzel: {value}",

1809
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,3 @@
packages:
- "apps/*"
- "packages/*"
# Allow lifecycle scripts for packages that need to build native binaries
# Required for pnpm v10+ which blocks scripts by default
onlyBuiltDependencies:
- sharp
- esbuild
- prisma
- "@prisma/client"
- "@prisma/engines"