Compare commits

..

8 Commits

Author SHA1 Message Date
Cursor Agent e21328770d fix: add defensive checks for survey.endings in API and migration
- Add null check in app sync utils for recall info replacement
- Add null check in database migration for endings cleanup
2026-02-05 08:52:28 +00:00
Cursor Agent 2a4aa806db test: add tests for survey.endings null/undefined handling
- Verify endings defaults to empty array when null
- Verify endings defaults to empty array when undefined
- Verify endings is preserved when provided
2026-02-05 08:51:03 +00:00
Cursor Agent 20d4a14e06 fix: prevent TypeError when accessing survey.endings.length
- Add default empty array for survey.endings in transformPrismaSurvey
- Add optional chaining to all survey.endings accesses in runtime code
- Ensure defensive null checks throughout survey rendering components

Fixes FORMBRICKS-GR
2026-02-05 08:47:06 +00:00
Matti Nannt 7971b9b312 fix(security): upgrade pnpm and AWS SDK to fix vulnerabilities (#7192)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:29:17 +00:00
Johannes 1143f58ba5 fix: refresh invite expiration when sharing link (#7198)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-04 13:28:25 +00:00
Balázs Úr 47fe3c73dd fix: Hungarian translations (#7199)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-04 13:26:27 +00:00
Dhruwang Jariwala 727e586b16 feat: responseID in response table (#7195) 2026-02-04 09:59:37 +00:00
Theodór Tómas 4a9b4d52ca fix: resolve infinite re-render loop in Survey Editor (#7142) 2026-02-04 05:03:09 +00:00
50 changed files with 1512 additions and 1059 deletions
-16
View File
@@ -1,20 +1,4 @@
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,
},
],
},
},
],
};
+10 -18
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine3.22 AS base
FROM node:24-alpine3.23 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@9.15.9 --activate
RUN corepack prepare pnpm@10.28.2 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -69,20 +69,14 @@ 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
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 \
# 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 \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -113,15 +107,13 @@ 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
@@ -134,7 +126,9 @@ 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
RUN npm install -g prisma@6
# 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
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -144,10 +138,8 @@ 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 corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
@@ -316,6 +316,14 @@ 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"),
@@ -376,24 +384,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);
@@ -414,6 +422,7 @@ export const generateResponseTableColumns = (
const baseColumns = [
personColumn,
singleUseIdColumn,
responseIdColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,
@@ -38,7 +38,7 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
}
});
}
surveyTemp.endings.forEach((ending) => {
(surveyTemp.endings ?? []).forEach((ending) => {
if (ending.type === "endScreen") {
languages.forEach((language) => {
if (ending.headline && ending.headline[language]?.includes("recall:")) {
+2 -1
View File
@@ -323,6 +323,7 @@ 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
@@ -928,7 +929,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/invited_on: 83476ce4bcdfc3ccf524d1cd91b758a8
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
environments/settings/general/invites_failed: 180ffb8db417050227cc2b2ea74b7aae
environments/settings/general/leave_organization: e74132cb4a0dc98c41e61ea3b2dd268b
environments/settings/general/leave_organization_description: 2d0cd65e4e78a9b2835cf88c4de407fb
+2 -1
View File
@@ -350,6 +350,7 @@
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
@@ -989,7 +990,7 @@
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invited_on": "Eingeladen am {date}",
"invite_expires_on": "Einladung läuft ab 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.",
+6 -5
View File
@@ -350,6 +350,7 @@
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
"restart": "Restart",
"role": "Role",
@@ -989,7 +990,7 @@
"from_your_organization": "from your organization",
"invitation_sent_once_more": "Invitation sent once more.",
"invite_deleted_successfully": "Invite deleted successfully",
"invited_on": "Invited on {date}",
"invite_expires_on": "Invite expires 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.",
@@ -1267,14 +1268,13 @@
"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,6 +1282,7 @@
"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",
@@ -1414,11 +1415,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",
@@ -3094,4 +3095,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)"
}
}
}
+2 -1
View File
@@ -350,6 +350,7 @@
"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",
@@ -989,7 +990,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",
"invited_on": "Invitado el {date}",
"invite_expires_on": "La invitación expira 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.",
+2 -1
View File
@@ -350,6 +350,7 @@
"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",
@@ -989,7 +990,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",
"invited_on": "Invité le {date}",
"invite_expires_on": "L'invitation expire 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é.",
+28 -27
View File
@@ -254,7 +254,7 @@
"label": "Címke",
"language": "Nyelv",
"learn_more": "Tudjon meg többet",
"license_expired": "Licenc lejárt",
"license_expired": "A licenc lejárt",
"light_overlay": "Világos rávetítés",
"limits_reached": "Korlátok elérve",
"link": "Összekapcsolás",
@@ -350,6 +350,7 @@
"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",
@@ -462,7 +463,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 licenced lejárt. Kérjük, újítsd meg, hogy továbbra is használhasd a vállalati funkciókat."
"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."
},
"emails": {
"accept": "Elfogadás",
@@ -811,7 +812,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",
@@ -820,7 +821,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 eltávolítani ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
"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:",
"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",
@@ -851,11 +852,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.",
@@ -989,7 +990,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",
"invited_on": "Meghívva ekkor: {date}",
"invite_expires_on": "A meghívó lejár: {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.",
@@ -1004,8 +1005,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.",
@@ -1105,8 +1106,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",
@@ -1129,7 +1130,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:",
@@ -1273,7 +1274,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ítési típus",
"display_type": "Megjelenített típus",
"divide": "Osztás /",
"does_not_contain": "Nem tartalmazza",
"does_not_end_with": "Nem ezzel végződik",
@@ -1281,7 +1282,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ő menü",
"dropdown": "Legördülő",
"duplicate_block": "Blokk kettőzése",
"duplicate_question": "Kérdés kettőzése",
"edit_link": "Hivatkozás szerkesztése",
@@ -1545,7 +1546,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",
@@ -1712,7 +1713,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ó",
@@ -1724,7 +1725,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ót, 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ókat, 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",
@@ -1949,8 +1950,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",
@@ -2013,8 +2014,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} 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_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_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",
@@ -2097,9 +2098,9 @@
"search_tags": "Címkék keresése…",
"tag": "Címke",
"tag_already_exists": "A címke már létezik",
"tag_deleted": "Címke törölve",
"tag_updated": "Címke frissítve",
"tags_merged": "Címkék egyesítve"
"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"
},
"teams": {
"manage_teams": "Csapatok kezelése",
@@ -2294,7 +2295,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 a következők egyikét",
"career_development_survey_question_5_subheader": "Válassza ki a következő lehetőségek 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ó",
@@ -2302,7 +2303,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 a következők egyikét",
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek 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",
+2 -1
View File
@@ -350,6 +350,7 @@
"request_trial_license": "トライアルライセンスをリクエスト",
"reset_to_default": "デフォルトにリセット",
"response": "回答",
"response_id": "回答ID",
"responses": "回答",
"restart": "再開",
"role": "役割",
@@ -989,7 +990,7 @@
"from_your_organization": "あなたの組織から",
"invitation_sent_once_more": "招待状を再度送信しました。",
"invite_deleted_successfully": "招待を正常に削除しました",
"invited_on": "{date}に招待",
"invite_expires_on": "招待は{date}に期限切れ",
"invites_failed": "招待に失敗しました",
"leave_organization": "組織を離れる",
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
+2 -1
View File
@@ -350,6 +350,7 @@
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"response": "Antwoord",
"response_id": "Antwoord-ID",
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
@@ -989,7 +990,7 @@
"from_your_organization": "vanuit uw organisatie",
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
"invited_on": "Uitgenodigd op {date}",
"invite_expires_on": "Uitnodiging verloopt 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.",
+2 -1
View File
@@ -350,6 +350,7 @@
"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ê",
@@ -989,7 +990,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invited_on": "Convidado em {date}",
"invite_expires_on": "O convite expira 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.",
+2 -1
View File
@@ -350,6 +350,7 @@
"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",
@@ -989,7 +990,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
"invited_on": "Convidado em {date}",
"invite_expires_on": "O convite expira 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.",
+2 -1
View File
@@ -350,6 +350,7 @@
"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",
@@ -989,7 +990,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",
"invited_on": "Invitat pe {date}",
"invite_expires_on": "Invitația expiră 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.",
+2 -1
View File
@@ -350,6 +350,7 @@
"request_trial_license": "Запросить пробную лицензию",
"reset_to_default": "Сбросить по умолчанию",
"response": "Ответ",
"response_id": "ID ответа",
"responses": "Ответы",
"restart": "Перезапустить",
"role": "Роль",
@@ -989,7 +990,7 @@
"from_your_organization": "из вашей организации",
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
"invite_deleted_successfully": "Приглашение успешно удалено",
"invited_on": "Приглашён {date}",
"invite_expires_on": "Приглашение истекает {date}",
"invites_failed": "Не удалось отправить приглашения",
"leave_organization": "Покинуть организацию",
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
+2 -1
View File
@@ -350,6 +350,7 @@
"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",
@@ -989,7 +990,7 @@
"from_your_organization": "från din organisation",
"invitation_sent_once_more": "Inbjudan skickad igen.",
"invite_deleted_successfully": "Inbjudan borttagen",
"invited_on": "Inbjuden den {date}",
"invite_expires_on": "Inbjudan går ut 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.",
+2 -1
View File
@@ -350,6 +350,7 @@
"request_trial_license": "申请试用许可证",
"reset_to_default": "重置为 默认",
"response": "响应",
"response_id": "响应 ID",
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
@@ -989,7 +990,7 @@
"from_your_organization": "来自你的组织",
"invitation_sent_once_more": "再次发送邀请。",
"invite_deleted_successfully": "邀请 删除 成功",
"invited_on": "邀于 {date}",
"invite_expires_on": "邀请将于 {date} 过期",
"invites_failed": "邀请失败",
"leave_organization": "离开 组织",
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
+2 -1
View File
@@ -350,6 +350,7 @@
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
"response_id": "回應 ID",
"responses": "回應",
"restart": "重新開始",
"role": "角色",
@@ -989,7 +990,7 @@
"from_your_organization": "來自您的組織",
"invitation_sent_once_more": "已再次發送邀請。",
"invite_deleted_successfully": "邀請已成功刪除",
"invited_on": "邀請於 '{'date'}'",
"invite_expires_on": "邀請於 '{'date'}' 過期",
"invites_failed": "邀請失敗",
"leave_organization": "離開組織",
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
@@ -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,6 +74,8 @@ export function LocalizedEditor({
[id, isInvalid, localSurvey.languages, value]
);
const [, startTransition] = useTransition();
return (
<div className="relative w-full">
<Editor
@@ -109,44 +111,45 @@ export function LocalizedEditor({
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
}
// Check if the elements still exists before updating
const currentElement = elements[elementIdx];
// 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;
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;
// 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) {
// 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) {
return;
}
}
// For welcome cards, check if it exists
if (isWelcomeCard && !localSurvey.welcomeCard) {
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement({ [id]: translatedContent });
return;
}
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 });
}
// 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}
@@ -110,7 +110,7 @@ export const QuotaModal = ({
],
},
action: quota?.action || "endSurvey",
endingCardId: quota?.endingCardId || survey.endings[0]?.id || null,
endingCardId: quota?.endingCardId || survey.endings?.[0]?.id || null,
countPartialSubmissions: quota?.countPartialSubmissions || false,
surveyId: survey.id,
};
@@ -2,6 +2,7 @@
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";
@@ -23,7 +24,7 @@ import {
getMembershipsByUserId,
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -57,30 +58,57 @@ const ZCreateInviteTokenAction = z.object({
inviteId: ZUuid,
});
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"],
},
],
});
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);
const invite = await getInvite(parsedInput.inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
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 inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
});
)
);
const ZDeleteMembershipAction = z.object({
userId: ZId,
@@ -191,6 +219,7 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
invite?.creator?.name ?? "",
updatedInvite.name ?? ""
);
return updatedInvite;
}
)
@@ -80,6 +80,7 @@ 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);
@@ -99,6 +100,7 @@ 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);
@@ -47,8 +47,8 @@ export const MembersInfo = ({
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invited_on", {
date: getFormattedDateTimeString(member.createdAt),
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: getFormattedDateTimeString(member.expiresAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>
@@ -9,7 +9,14 @@ import {
} from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { TInvitee } from "../types/invites";
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
import {
deleteInvite,
getInvite,
getInvitesByOrganizationId,
inviteUser,
refreshInviteExpiration,
resendInvite,
} from "./invite";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -46,32 +53,129 @@ const mockInvite: Invite = {
teamIds: [],
};
describe("resendInvite", () => {
describe("refreshInviteExpiration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
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 });
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", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
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 prisma error", async () => {
test("throws DatabaseError on other prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
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);
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);
await expect(resendInvite("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(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma error", async () => {
test("throws error if non-prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
vi.mocked(prisma.invite.update).mockRejectedValue(error);
await expect(resendInvite("invite-1")).rejects.toThrow("db");
});
});
@@ -13,44 +13,21 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
name: true,
creator: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
const updatedInvite = await prisma.invite.update({
where: {
id: inviteId,
},
where: { id: inviteId },
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
},
select: {
id: true,
email: true,
name: true,
organizationId: true,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
},
});
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
return updatedInvite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
throw new ResourceNotFoundError("Invite", inviteId);
}
throw new DatabaseError(error.message);
}
@@ -58,6 +35,16 @@ export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "emai
}
};
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()]);
@@ -115,7 +115,7 @@ export const ElementFormInput = ({
: currentElement.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isWelcomeCard, isEndingCard, currentElement?.id]);
const endingCard = localSurvey.endings.find((ending) => ending.id === elementId);
const endingCard = (localSurvey.endings ?? []).find((ending) => ending.id === elementId);
const surveyLanguageCodes = useMemo(
() => extractLanguageCodes(localSurvey.languages),
@@ -57,7 +57,7 @@ export const getEndingCardText = (
questionIdx: number
): TI18nString => {
const endingCardIndex = questionIdx - questions.length;
const card = survey.endings[endingCardIndex];
const card = survey.endings?.[endingCardIndex];
if (card?.type === "endScreen") {
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
@@ -85,7 +85,7 @@ export const EditorCardMenu = ({
const elements = getElementsFromBlocks(survey.blocks);
const isDeleteDisabled =
cardType === "element" ? elements.length === 1 : survey.type === "link" && survey.endings.length === 1;
cardType === "element" ? elements.length === 1 : survey.type === "link" && (survey.endings?.length ?? 0) === 1;
const availableElementTypes = isCxMode ? getCXElementNameMap(t) : getElementNameMap(t);
@@ -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, validateSurveyElementsInBatch } from "../lib/validation";
import { isEndingCardValid, isWelcomeCardValid, validateElement } from "../lib/validation";
interface ElementsViewProps {
localSurvey: TSurvey;
@@ -211,35 +211,6 @@ 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];
@@ -250,7 +221,6 @@ 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) => {
@@ -296,13 +266,6 @@ 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;
@@ -344,9 +307,6 @@ export const ElementsView = ({
});
// Apply side effects after state update is queued
if (invalidElementsUpdate) {
setInvalidElements(invalidElementsUpdate);
}
if (newActiveElementId) {
setActiveElementId(newActiveElementId);
}
@@ -764,23 +724,67 @@ export const ElementsView = ({
setLocalSurvey(result.data);
};
//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
);
});
// Validate survey when changes are made to languages or elements
// using set for O(1) lookup
useEffect(
() => {
if (!invalidElements) return;
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
setInvalidElements(updatedInvalidElements);
}
}, [elements, surveyLanguages, invalidElements, setInvalidElements]);
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,
]
);
useEffect(() => {
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
@@ -791,7 +795,7 @@ export const ElementsView = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeElementId, setActiveElementId]);
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -86,6 +86,7 @@ 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);
+36
View File
@@ -102,6 +102,42 @@ describe("Survey Utils", () => {
expect(result.segment).toBeNull();
expect(result.id).toBe("surveyJs");
});
test("should default endings to empty array when null", () => {
const surveyPrisma = {
id: "survey5",
name: "Survey with null endings",
displayPercentage: "100",
segment: null,
endings: null,
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result.endings).toEqual([]);
});
test("should default endings to empty array when undefined", () => {
const surveyPrisma = {
id: "survey6",
name: "Survey with undefined endings",
displayPercentage: "100",
segment: null,
endings: undefined,
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result.endings).toEqual([]);
});
test("should preserve endings when provided", () => {
const surveyPrisma = {
id: "survey7",
name: "Survey with endings",
displayPercentage: "100",
segment: null,
endings: [{ id: "ending1", type: "endScreen" }],
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result.endings).toEqual([{ id: "ending1", type: "endScreen" }]);
});
});
describe("buildWhereClause", () => {
+2
View File
@@ -21,6 +21,8 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
segment,
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
// Ensure endings is always an array to prevent runtime errors
endings: surveyPrisma.endings ?? [],
} as T;
return transformedSurvey;
@@ -102,7 +102,7 @@ export const PreviewSurvey = ({
}
// check the endings
const ending = survey.endings.find((e) => e.id === newElementId);
const ending = (survey.endings ?? []).find((e) => e.id === newElementId);
if (ending) {
setBlockId(ending.id);
return;
@@ -119,7 +119,7 @@ export const PreviewSurvey = ({
const onFinished = () => {
// close modal if there are no elements left
if (survey.type === "app" && survey.endings.length === 0) {
if (survey.type === "app" && (survey.endings?.length ?? 0) === 0) {
setIsModalOpen(false);
setTimeout(() => {
if (survey.blocks[0]) {
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@formbricks/web",
"version": "0.0.0",
"packageManager": "pnpm@9.15.9",
"packageManager": "pnpm@10.28.2",
"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.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0",
"@boxyhq/saml-jackson": "1.52.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
+6 -4
View File
@@ -73,9 +73,9 @@
]
},
"engines": {
"node": ">=16.0.0"
"node": ">=20.0.0"
},
"packageManager": "pnpm@9.15.9",
"packageManager": "pnpm@10.28.2",
"nextBundleAnalysis": {
"budget": 358400,
"budgetPercentIncreaseRed": 20,
@@ -90,10 +90,12 @@
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26",
"systeminformation": "5.27.14",
"qs": ">=6.14.1"
"qs": ">=6.14.1",
"fast-xml-parser": ">=5.3.4",
"diff": ">=8.0.3"
},
"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"
"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 | fast-xml-parser (Dependabot #270) - awaiting @boxyhq/saml-jackson update | diff (Dependabot #269) - awaiting @microsoft/api-extractor update"
},
"patchedDependencies": {
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
-1
View File
@@ -10,7 +10,6 @@
"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",
@@ -62,7 +62,7 @@ export const removeEmptyImageAndVideoUrlsFromElements: MigrationScript = {
delete cleanedWelcomeCard.videoUrl;
}
const cleanedEndings = survey.endings.map((ending) => {
const cleanedEndings = (survey.endings ?? []).map((ending) => {
const cleanedEnding = { ...ending };
if (cleanedEnding.imageUrl === "") {
delete cleanedEnding.imageUrl;
@@ -170,7 +170,7 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr
// Run Prisma migrate
// throws when migrate deploy fails
await execAsync(`pnpm prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
await execAsync(`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`);
@@ -83,37 +83,17 @@ export const addPageUrlEventListeners = (): void => {
// Monkey patch history methods if not already done
if (!isHistoryPatched) {
try {
// eslint-disable-next-line @typescript-eslint/unbound-method -- We need to access the original method
const originalPushState = history.pushState;
// eslint-disable-next-line @typescript-eslint/unbound-method -- We need to access the original method
const originalReplaceState = history.replaceState;
// eslint-disable-next-line @typescript-eslint/unbound-method -- We need to access the original method
const originalPushState = history.pushState;
// Use Object.defineProperty to override read-only properties
Object.defineProperty(history, "pushState", {
value: function (...args: Parameters<typeof originalPushState>) {
originalPushState.apply(this, args);
const event = new Event("pushstate");
window.dispatchEvent(event);
},
writable: true,
configurable: true,
});
// eslint-disable-next-line func-names -- We need an anonymous function here
history.pushState = function (...args) {
originalPushState.apply(this, args);
const event = new Event("pushstate");
window.dispatchEvent(event);
};
Object.defineProperty(history, "replaceState", {
value: function (...args: Parameters<typeof originalReplaceState>) {
originalReplaceState.apply(this, args);
const event = new Event("replacestate");
window.dispatchEvent(event);
},
writable: true,
configurable: true,
});
isHistoryPatched = true;
} catch (error) {
console.error("🧱 Formbricks - Failed to patch history methods:", error);
}
isHistoryPatched = true;
}
events.forEach((event) => {
+3 -3
View File
@@ -37,9 +37,9 @@
"author": "Formbricks <hola@formbricks.com>",
"dependencies": {
"@formbricks/logger": "workspace:*",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0"
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
+4 -4
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": "Válassz egy lehetőséget",
"select_options": "Válassz lehetőségeket",
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"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 spam elleni védelmet."
"title": "Ez az eszköz nem támogatja a szemét elleni védekezést."
},
"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 ember vagy."
"title": "Nem tudtuk ellenőrizni, hogy Ön ember-e."
},
"value_must_contain": "Az értéknek tartalmaznia kell ezt: {value}",
"value_must_equal": "Az értéknek egyenlőnek kell lennie ezzel: {value}",
@@ -13,7 +13,7 @@ export function ProgressBar({ survey, blockId }: ProgressBarProps) {
[survey.blocks, blockId]
);
const endingCardIds = useMemo(() => survey.endings.map((ending) => ending.id), [survey.endings]);
const endingCardIds = useMemo(() => (survey.endings ?? []).map((ending) => ending.id), [survey.endings]);
const calculateProgress = useCallback(
(blockIndex: number) => {
@@ -77,7 +77,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
close();
}
},
props.survey.endings.length ? 3000 : 0 // close modal automatically after 3 seconds if no ending is enabled; otherwise, close immediately
(props.survey.endings?.length ?? 0) ? 3000 : 0 // close modal automatically after 3 seconds if no ending is enabled; otherwise, close immediately
);
}
}}
@@ -422,7 +422,7 @@ export function Survey({
const evaluateLogicAndGetNextBlockId = (
data: TResponseData
): { nextBlockId: string | undefined; calculatedVariables: TResponseVariables } => {
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
const firstEndingId = (survey.endings?.length ?? 0) > 0 ? survey.endings[0].id : undefined;
if (blockId === "start")
return { nextBlockId: localSurvey.blocks[0]?.id || firstEndingId, calculatedVariables: {} };
@@ -657,7 +657,7 @@ export function Survey({
setIsSurveyFinished(finished);
const endingId = nextBlockId
? localSurvey.endings.find((ending) => ending.id === nextBlockId)?.id
? (localSurvey.endings ?? []).find((ending) => ending.id === nextBlockId)?.id
: undefined;
onChange(surveyResponseData);
@@ -776,7 +776,7 @@ export function Survey({
/>
);
} else if (blockIdx >= localSurvey.blocks.length) {
const endingCard = localSurvey.endings.find((ending) => {
const endingCard = (localSurvey.endings ?? []).find((ending) => {
return ending.id === blockId;
});
if (endingCard) {
@@ -86,7 +86,7 @@ export function WelcomeCard({
const calculateTimeToComplete = () => {
const questions = getElementsFromSurveyBlocks(survey.blocks);
let totalCards = questions.length;
if (survey.endings.length > 0) totalCards += 1;
if ((survey.endings?.length ?? 0) > 0) totalCards += 1;
let idx = calculateElementIdx(survey, 0, totalCards);
if (idx === 0.5) {
idx = 1;
@@ -164,7 +164,7 @@ export function StackedCardsContainer({
) : (
blockIdxTemp !== undefined &&
[prevBlockIdx, currentBlockIdx, nextBlockIdx, nextBlockIdx + 1].map((dynamicBlockIndex, index) => {
const hasEndingCard = survey.endings.length > 0;
const hasEndingCard = (survey.endings?.length ?? 0) > 0;
// Check for hiding extra card
if (dynamicBlockIndex > survey.blocks.length + (hasEndingCard ? 0 : -1)) return;
const offset = index - 1;
+1 -1
View File
@@ -87,7 +87,7 @@ export const calculateElementIdx = (
const currentQuestion = questions[currentQustionIdx];
const middleIdx = Math.floor(totalCards / 2);
const possibleNextBlockIds = getPossibleNextBlocks(survey.blocks, currentQuestion);
const endingCardIds = survey.endings.map((ending) => ending.id);
const endingCardIds = (survey.endings ?? []).map((ending) => ending.id);
// Convert block IDs to element IDs (get first element of each block)
const possibleNextQuestionIds = possibleNextBlockIds
+1038 -743
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -1,3 +1,12 @@
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"