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
50 changed files with 1035 additions and 1502 deletions
+16
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,
},
],
},
},
],
};
+18 -10
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/
@@ -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,
@@ -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:")) {
@@ -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),
+1 -2
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
+1 -2
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.",
+5 -6
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)"
}
}
}
+1 -2
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.",
+1 -2
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é.",
+27 -28
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",
+1 -2
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": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
+1 -2
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.",
+1 -2
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.",
+1 -2
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.",
+1 -2
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.",
+1 -2
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": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
+1 -2
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.",
+1 -2
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": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
+1 -2
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": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
@@ -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}
@@ -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,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;
}
)
@@ -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);
@@ -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>
@@ -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");
});
});
@@ -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()]);
@@ -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 ?? 0) === 1;
cardType === "element" ? elements.length === 1 : survey.type === "link" && survey.endings.length === 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, 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, {
@@ -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);
-36
View File
@@ -102,42 +102,6 @@ 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,8 +21,6 @@ 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) === 0) {
if (survey.type === "app" && survey.endings.length === 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@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",
+4 -6
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,12 +90,10 @@
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26",
"systeminformation": "5.27.14",
"qs": ">=6.14.1",
"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 | 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"
+1
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",
@@ -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(`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`);
+3 -3
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:*",
+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": "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}",
@@ -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 ?? 0) ? 3000 : 0 // close modal automatically after 3 seconds if no ending is enabled; otherwise, close immediately
props.survey.endings.length ? 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) > 0 ? survey.endings[0].id : undefined;
const firstEndingId = survey.endings.length > 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) > 0) totalCards += 1;
if (survey.endings.length > 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) > 0;
const hasEndingCard = survey.endings.length > 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
+745 -1040
View File
File diff suppressed because it is too large Load Diff
-9
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"