mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-17 19:49:36 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 208d83eb08 | |||
| 0a7482da0f | |||
| c286a3330a |
+12
-14
@@ -106,20 +106,18 @@ export const ResponseCardModal = ({
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
<div className="my-3">
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
user={user}
|
||||
environment={environment}
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
updateResponse={updateResponse}
|
||||
updateResponseList={updateResponseList}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
user={user}
|
||||
environment={environment}
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
updateResponse={updateResponse}
|
||||
updateResponseList={updateResponseList}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
locale={locale}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
+28
-6
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
@@ -14,20 +15,28 @@ import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { ResponseSampleModal } from "./ResponseSampleModal";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryOpenText;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||
export const OpenTextSummary = ({
|
||||
elementSummary,
|
||||
environment,
|
||||
survey,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: OpenTextSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||
);
|
||||
@@ -54,12 +63,16 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableRow
|
||||
key={response.id}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => setSelectedResponseId(response.id)}>
|
||||
<TableCell className="w-1/4">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
href={`/environments/${environment.id}/contacts/${response.contact.id}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
@@ -84,7 +97,7 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
|
||||
<TableCell className="w-1/6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/6">
|
||||
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
|
||||
<IdBadge id={response.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -100,6 +113,15 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponseSampleModal
|
||||
responseId={selectedResponseId}
|
||||
onClose={() => setSelectedResponseId(null)}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
getResponseAction,
|
||||
getTagsByEnvironmentIdAction,
|
||||
} from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ResponseSampleModalProps {
|
||||
responseId: string | null;
|
||||
onClose: () => void;
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ResponseSampleModal = ({
|
||||
responseId,
|
||||
onClose,
|
||||
survey,
|
||||
environment,
|
||||
isReadOnly,
|
||||
locale,
|
||||
}: ResponseSampleModalProps) => {
|
||||
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
|
||||
const [tags, setTags] = useState<TTag[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Cache fetched data per response ID to avoid re-fetching on re-open
|
||||
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!responseId) return;
|
||||
|
||||
const cached = cache.current.get(responseId);
|
||||
if (cached) {
|
||||
setResponse(cached.response);
|
||||
setTags(cached.tags);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setResponse(null);
|
||||
|
||||
Promise.all([
|
||||
getResponseAction({ responseId }),
|
||||
getTagsByEnvironmentIdAction({ environmentId: environment.id }),
|
||||
])
|
||||
.then(([responseResult, tagsResult]) => {
|
||||
const fetchedResponse = responseResult?.data ?? null;
|
||||
const fetchedTags = tagsResult?.data ?? [];
|
||||
|
||||
if (fetchedResponse) {
|
||||
const entry = { response: fetchedResponse as TResponseWithQuotas, tags: fetchedTags };
|
||||
cache.current.set(responseId, entry);
|
||||
setResponse(entry.response);
|
||||
setTags(entry.tags);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [responseId, environment.id]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
|
||||
<DialogContent width="wide">
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>Survey Response Details</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>Full response details</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
{isLoading || !response ? (
|
||||
<div className="py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={response}
|
||||
environment={environment}
|
||||
environmentTags={tags}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+11
-2
@@ -41,9 +41,17 @@ interface SummaryListProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
|
||||
export const SummaryList = ({
|
||||
summary,
|
||||
environment,
|
||||
responseCount,
|
||||
survey,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const setFilter = (
|
||||
@@ -113,9 +121,10 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
<OpenTextSummary
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+3
@@ -51,6 +51,7 @@ interface SummaryPageProps {
|
||||
locale: TUserLocale;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
isQuotasAllowed: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -60,6 +61,7 @@ export const SummaryPage = ({
|
||||
locale,
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
isReadOnly,
|
||||
}: SummaryPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -230,6 +232,7 @@ export const SummaryPage = ({
|
||||
survey={surveyMemoized}
|
||||
environment={environment}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+1
@@ -91,6 +91,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
|
||||
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
@@ -79,6 +80,17 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.storage.uploadPerEnvironment, environmentId);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
|
||||
const maxFileUploadSize = isBiggerFileUploadAllowed
|
||||
? MAX_FILE_UPLOAD_SIZES.big
|
||||
|
||||
@@ -640,6 +640,7 @@ checksums:
|
||||
environments/contacts/attributes_msg_email_or_userid_required: febc8b0cda4dd45d2c3cdb1ac2d45dcb
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/collapse_response: d697c361333407ba0ddef3e5bb277f5d
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
@@ -659,6 +660,7 @@ checksums:
|
||||
environments/contacts/edit_attribute_values_description: 21593dfaf4cad965ffc17685bc005509
|
||||
environments/contacts/edit_attributes: a5c3b540441d34b4c0b7faab8f0f0c89
|
||||
environments/contacts/edit_attributes_success: 39f93b1a6f1605bc5951f4da5847bb22
|
||||
environments/contacts/expand_response: f3b8ca2a0cf6b7af31c318b5e0dbbea8
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder Benutzer-ID ist erforderlich. Die vorhandenen Werte wurden beibehalten.",
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"collapse_response": "Antwort einklappen",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
|
||||
"edit_attributes": "Attribute bearbeiten",
|
||||
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
|
||||
"expand_response": "Antwort ausklappen",
|
||||
"generate_personal_link": "Persönlichen Link generieren",
|
||||
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
|
||||
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Either email or user ID is required. The existing values were preserved.",
|
||||
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
|
||||
"collapse_response": "Collapse response",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
|
||||
"edit_attributes": "Edit Attributes",
|
||||
"edit_attributes_success": "Contact attributes updated successfully",
|
||||
"expand_response": "Expand response",
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Se requiere el correo electrónico o el ID de usuario. Se conservaron los valores existentes.",
|
||||
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
|
||||
"collapse_response": "Contraer respuesta",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"create_attribute": "Crear atributo",
|
||||
"create_new_attribute": "Crear atributo nuevo",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Cambia los valores de atributos específicos para este contacto.",
|
||||
"edit_attributes": "Editar atributos",
|
||||
"edit_attributes_success": "Atributos del contacto actualizados correctamente",
|
||||
"expand_response": "Expandir respuesta",
|
||||
"generate_personal_link": "Generar enlace personal",
|
||||
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
|
||||
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "L'e-mail ou l'identifiant utilisateur est requis. Les valeurs existantes ont été conservées.",
|
||||
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
|
||||
"collapse_response": "Réduire la réponse",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"create_attribute": "Créer un attribut",
|
||||
"create_new_attribute": "Créer un nouvel attribut",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Modifiez les valeurs d'attributs spécifiques pour ce contact.",
|
||||
"edit_attributes": "Modifier les attributs",
|
||||
"edit_attributes_success": "Attributs du contact mis à jour avec succès",
|
||||
"expand_response": "Développer la réponse",
|
||||
"generate_personal_link": "Générer un lien personnel",
|
||||
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
|
||||
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s) : {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Vagy e-mail-cím, vagy felhasználó-azonosító szükséges. A meglévő értékek megmaradtak.",
|
||||
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
|
||||
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
|
||||
"collapse_response": "Válasz összecsukása",
|
||||
"contact_deleted_successfully": "A partner sikeresen törölve",
|
||||
"create_attribute": "Attribútum létrehozása",
|
||||
"create_new_attribute": "Új attribútum létrehozása",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Bizonyos attribútumok értékének megváltoztatása ennél a partnernél.",
|
||||
"edit_attributes": "Attribútumok szerkesztése",
|
||||
"edit_attributes_success": "A partner attribútumai sikeresen frissítve",
|
||||
"expand_response": "Válasz kibontása",
|
||||
"generate_personal_link": "Személyes hivatkozás előállítása",
|
||||
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
|
||||
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "メールアドレスまたはユーザーIDのいずれかが必要です。既存の値は保持されました。",
|
||||
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
|
||||
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
|
||||
"collapse_response": "回答を折りたたむ",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"create_attribute": "属性を作成",
|
||||
"create_new_attribute": "新しい属性を作成",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "この連絡先の特定の属性の値を変更します。",
|
||||
"edit_attributes": "属性を編集",
|
||||
"edit_attributes_success": "連絡先属性が正常に更新されました",
|
||||
"expand_response": "回答を展開する",
|
||||
"generate_personal_link": "個人リンクを生成",
|
||||
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
|
||||
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "E-mail of gebruikers-ID is vereist. De bestaande waarden zijn behouden.",
|
||||
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"collapse_response": "Antwoord inklappen",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"create_attribute": "Attribuut aanmaken",
|
||||
"create_new_attribute": "Nieuw attribuut aanmaken",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Wijzig de waarden voor specifieke attributen voor dit contact.",
|
||||
"edit_attributes": "Attributen bewerken",
|
||||
"edit_attributes_success": "Contactattributen succesvol bijgewerkt",
|
||||
"expand_response": "Antwoord uitklappen",
|
||||
"generate_personal_link": "Persoonlijke link genereren",
|
||||
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
|
||||
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "E-mail ou ID de usuário é obrigatório. Os valores existentes foram preservados.",
|
||||
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
|
||||
"collapse_response": "Recolher resposta",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contato.",
|
||||
"edit_attributes": "Editar atributos",
|
||||
"edit_attributes_success": "Atributos do contato atualizados com sucesso",
|
||||
"expand_response": "Expandir resposta",
|
||||
"generate_personal_link": "Gerar link pessoal",
|
||||
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
|
||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "É necessário um email ou ID de utilizador. Os valores existentes foram preservados.",
|
||||
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
|
||||
"collapse_response": "Recolher resposta",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contacto.",
|
||||
"edit_attributes": "Editar atributos",
|
||||
"edit_attributes_success": "Atributos do contacto atualizados com sucesso",
|
||||
"expand_response": "Expandir resposta",
|
||||
"generate_personal_link": "Gerar Link Pessoal",
|
||||
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
|
||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Este necesar fie un email, fie un ID de utilizator. Valorile existente au fost păstrate.",
|
||||
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"collapse_response": "Restrânge răspunsul",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"create_attribute": "Creează atribut",
|
||||
"create_new_attribute": "Creează atribut nou",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Modifică valorile anumitor atribute pentru acest contact.",
|
||||
"edit_attributes": "Editează atributele",
|
||||
"edit_attributes_success": "Atributele contactului au fost actualizate cu succes",
|
||||
"expand_response": "Extinde răspunsul",
|
||||
"generate_personal_link": "Generează link personal",
|
||||
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
|
||||
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Требуется либо email, либо user ID. Существующие значения были сохранены.",
|
||||
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
|
||||
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
|
||||
"collapse_response": "Свернуть ответ",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"create_attribute": "Создать атрибут",
|
||||
"create_new_attribute": "Создать новый атрибут",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Измените значения определённых атрибутов для этого контакта.",
|
||||
"edit_attributes": "Редактировать атрибуты",
|
||||
"edit_attributes_success": "Атрибуты контакта успешно обновлены",
|
||||
"expand_response": "Развернуть ответ",
|
||||
"generate_personal_link": "Сгенерировать персональную ссылку",
|
||||
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
|
||||
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Antingen e-post eller användar-ID krävs. De befintliga värdena har bevarats.",
|
||||
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
|
||||
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
|
||||
"collapse_response": "Dölj svar",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"create_attribute": "Skapa attribut",
|
||||
"create_new_attribute": "Skapa nytt attribut",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Ändra värdena för specifika attribut för denna kontakt.",
|
||||
"edit_attributes": "Redigera attribut",
|
||||
"edit_attributes_success": "Kontaktens attribut har uppdaterats",
|
||||
"expand_response": "Visa svar",
|
||||
"generate_personal_link": "Generera personlig länk",
|
||||
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
|
||||
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "Email veya kullanıcı kimliği gereklidir. Mevcut değerler korundu.",
|
||||
"attributes_msg_new_attribute_created": "\"{key}\" adında \"{dataType}\" türünde yeni özellik oluşturuldu",
|
||||
"attributes_msg_userid_already_exists": "Kullanıcı kimliği bu ortamda zaten mevcut ve güncellenmedi.",
|
||||
"collapse_response": "Yanıtı daralt",
|
||||
"contact_deleted_successfully": "Contact başarıyla silindi",
|
||||
"create_attribute": "Özellik oluştur",
|
||||
"create_new_attribute": "Yeni özellik oluştur",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "Bu kişi için belirli özelliklerin değerlerini değiştirin.",
|
||||
"edit_attributes": "Özellikleri Düzenle",
|
||||
"edit_attributes_success": "Contact attributes başarıyla güncellendi",
|
||||
"expand_response": "Yanıtı genişlet",
|
||||
"generate_personal_link": "Kişisel Bağlantı Oluştur",
|
||||
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir survey seçin.",
|
||||
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harfler, rakamlar ve alt çizgi içermeli ve bir harfle başlamalıdır.",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "需要填写邮箱或用户ID。已保留现有值。",
|
||||
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
|
||||
"collapse_response": "收起回复",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"create_attribute": "创建属性",
|
||||
"create_new_attribute": "创建新属性",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "更改此联系人的特定属性值。",
|
||||
"edit_attributes": "编辑属性",
|
||||
"edit_attributes_success": "联系人属性更新成功",
|
||||
"expand_response": "展开回复",
|
||||
"generate_personal_link": "生成个人链接",
|
||||
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
|
||||
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
|
||||
|
||||
@@ -676,6 +676,7 @@
|
||||
"attributes_msg_email_or_userid_required": "必須填寫電子郵件或使用者 ID。已保留現有值。",
|
||||
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
|
||||
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
|
||||
"collapse_response": "收合回應",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"create_attribute": "建立屬性",
|
||||
"create_new_attribute": "建立新屬性",
|
||||
@@ -695,6 +696,7 @@
|
||||
"edit_attribute_values_description": "變更此聯絡人特定屬性的值。",
|
||||
"edit_attributes": "編輯屬性",
|
||||
"edit_attributes_success": "聯絡人屬性已成功更新",
|
||||
"expand_response": "展開回應",
|
||||
"generate_personal_link": "產生個人連結",
|
||||
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
|
||||
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { createTag } from "@/lib/tag/service";
|
||||
import { createTag, getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -175,6 +175,32 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetTagsByEnvironmentIdAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getTagsByEnvironmentIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetTagsByEnvironmentIdAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getTagsByEnvironmentId(parsedInput.environmentId);
|
||||
});
|
||||
|
||||
const ZGetResponseAction = z.object({
|
||||
responseId: ZId,
|
||||
});
|
||||
|
||||
+1
-1
@@ -134,6 +134,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IdBadge id={responseId} />
|
||||
<SingleResponseCardMetadata response={response} locale={locale} />
|
||||
{tagsState?.map((tag) => (
|
||||
<Tag
|
||||
@@ -161,7 +162,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IdBadge id={responseId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-3
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface InfoIconButtonProps {
|
||||
@@ -26,9 +25,11 @@ const InfoIconButton = ({
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label={ariaLabel}>
|
||||
<button
|
||||
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
|
||||
aria-label={ariaLabel}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
|
||||
{tooltipContent}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -16,12 +16,7 @@ import { deleteResponseAction, getResponseAction } from "./actions";
|
||||
import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper";
|
||||
import { SingleResponseCardBody } from "./components/SingleResponseCardBody";
|
||||
import { SingleResponseCardHeader } from "./components/SingleResponseCardHeader";
|
||||
import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util";
|
||||
|
||||
export interface SingleResponseCardHeaderRenderProps {
|
||||
onDeleteClick: () => void;
|
||||
canResponseBeDeleted: boolean;
|
||||
}
|
||||
import { isValidValue } from "./util";
|
||||
|
||||
interface SingleResponseCardProps {
|
||||
survey: TSurvey;
|
||||
@@ -34,11 +29,6 @@ interface SingleResponseCardProps {
|
||||
isReadOnly: boolean;
|
||||
setSelectedResponseId?: (responseId: string | null) => void;
|
||||
locale: TUserLocale;
|
||||
/**
|
||||
* Optional render-prop to replace the default header. Receives helpers to
|
||||
* trigger the (shared) delete dialog so callers don't have to reimplement it.
|
||||
*/
|
||||
renderHeader?: (props: SingleResponseCardHeaderRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
export const SingleResponseCard = ({
|
||||
@@ -52,8 +42,7 @@ export const SingleResponseCard = ({
|
||||
isReadOnly,
|
||||
setSelectedResponseId,
|
||||
locale,
|
||||
renderHeader,
|
||||
}: Readonly<SingleResponseCardProps>) => {
|
||||
}: SingleResponseCardProps) => {
|
||||
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
|
||||
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
|
||||
const { t } = useTranslation();
|
||||
@@ -139,30 +128,19 @@ export const SingleResponseCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const canResponseBeDeleted = response.finished
|
||||
? true
|
||||
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="relative z-20 rounded-xl border border-slate-200 bg-white shadow-sm transition-all">
|
||||
{renderHeader ? (
|
||||
renderHeader({
|
||||
onDeleteClick: () => setDeleteDialogOpen(true),
|
||||
canResponseBeDeleted,
|
||||
})
|
||||
) : (
|
||||
<SingleResponseCardHeader
|
||||
pageType="response"
|
||||
response={response}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
user={user}
|
||||
isReadOnly={isReadOnly}
|
||||
setDeleteDialogOpen={setDeleteDialogOpen}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all">
|
||||
<SingleResponseCardHeader
|
||||
pageType="response"
|
||||
response={response}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
user={user}
|
||||
isReadOnly={isReadOnly}
|
||||
setDeleteDialogOpen={setDeleteDialogOpen}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<SingleResponseCardBody
|
||||
survey={survey}
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("rateLimitConfigs", () => {
|
||||
expect(rateLimitConfigs).toHaveProperty("auth");
|
||||
expect(rateLimitConfigs).toHaveProperty("api");
|
||||
expect(rateLimitConfigs).toHaveProperty("actions");
|
||||
expect(rateLimitConfigs).toHaveProperty("storage");
|
||||
});
|
||||
|
||||
test("should have all auth configurations", () => {
|
||||
@@ -81,6 +82,11 @@ describe("rateLimitConfigs", () => {
|
||||
"licenseRecheck",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should have all storage configurations", () => {
|
||||
const storageConfigs = Object.keys(rateLimitConfigs.storage);
|
||||
expect(storageConfigs).toEqual(["upload", "uploadPerEnvironment", "delete"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zod Validation", () => {
|
||||
@@ -89,6 +95,7 @@ describe("rateLimitConfigs", () => {
|
||||
...Object.values(rateLimitConfigs.auth),
|
||||
...Object.values(rateLimitConfigs.api),
|
||||
...Object.values(rateLimitConfigs.actions),
|
||||
...Object.values(rateLimitConfigs.storage),
|
||||
];
|
||||
|
||||
for (const config of allConfigs) {
|
||||
@@ -105,6 +112,7 @@ describe("rateLimitConfigs", () => {
|
||||
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.storage).forEach((config) => allNamespaces.push(config.namespace));
|
||||
|
||||
const uniqueNamespaces = new Set(allNamespaces);
|
||||
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
|
||||
@@ -143,6 +151,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
|
||||
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
|
||||
{ config: rateLimitConfigs.storage.uploadPerEnvironment, identifier: "storage-upload-env" },
|
||||
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
|
||||
];
|
||||
|
||||
@@ -172,6 +181,15 @@ describe("rateLimitConfigs", () => {
|
||||
expect(config.namespace).toBe("storage:upload");
|
||||
});
|
||||
|
||||
test("should properly configure storage upload per environment rate limit", async () => {
|
||||
const config = rateLimitConfigs.storage.uploadPerEnvironment;
|
||||
|
||||
// Verify configuration values
|
||||
expect(config.interval).toBe(60); // 1 minute
|
||||
expect(config.allowedPerInterval).toBe(100); // 100 requests per minute
|
||||
expect(config.namespace).toBe("storage:upload:environment");
|
||||
});
|
||||
|
||||
test("should properly configure storage delete rate limit", async () => {
|
||||
const config = rateLimitConfigs.storage.delete;
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ export const rateLimitConfigs = {
|
||||
|
||||
storage: {
|
||||
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
||||
uploadPerEnvironment: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "storage:upload:environment",
|
||||
}, // 100 per minute per environment
|
||||
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { LinkIcon, PencilIcon, RefreshCwIcon, TrashIcon } from "lucide-react";
|
||||
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
@@ -46,13 +46,6 @@ export const ContactControlBar = ({
|
||||
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
|
||||
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
|
||||
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
|
||||
const handleRefresh = () => {
|
||||
startRefreshTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePerson = async () => {
|
||||
setIsDeletingPerson(true);
|
||||
@@ -69,22 +62,18 @@ export const ContactControlBar = ({
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
if (isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: RefreshCwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
onClick: handleRefresh,
|
||||
isVisible: true,
|
||||
disabled: isRefreshing,
|
||||
iconClassName: isRefreshing ? "animate-spin" : undefined,
|
||||
},
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("environments.contacts.edit_attributes"),
|
||||
onClick: () => {
|
||||
setIsEditAttributesModalOpen(true);
|
||||
},
|
||||
isVisible: !isReadOnly,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
@@ -92,7 +81,7 @@ export const ContactControlBar = ({
|
||||
onClick: () => {
|
||||
setIsGenerateLinkModalOpen(true);
|
||||
},
|
||||
isVisible: !isReadOnly,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
@@ -100,7 +89,7 @@ export const ContactControlBar = ({
|
||||
onClick: () => {
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
isVisible: !isReadOnly,
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareTextIcon, TrashIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronUpIcon, MessageSquareTextIcon, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
@@ -10,9 +11,22 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import {
|
||||
deleteResponseAction,
|
||||
getResponseAction,
|
||||
} from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { ResponseTagsWrapper } from "@/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper";
|
||||
import { SingleResponseCardBody } from "@/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody";
|
||||
import {
|
||||
isSubmissionTimeMoreThan5Minutes,
|
||||
isValidValue,
|
||||
} from "@/modules/analysis/components/SingleResponseCard/util";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ResponseSurveyCardProps {
|
||||
@@ -39,72 +53,207 @@ export const ResponseSurveyCard = ({
|
||||
isReadOnly,
|
||||
}: Readonly<ResponseSurveyCardProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const surveyWithReplacedRecall = useMemo(() => replaceHeadlineRecall(survey, "default"), [survey]);
|
||||
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
|
||||
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
|
||||
|
||||
const surveyWithReplacedRecall = useMemo(
|
||||
() => replaceHeadlineRecall(survey, "default"),
|
||||
[survey]
|
||||
);
|
||||
|
||||
const skippedQuestions: string[][] = useMemo(() => {
|
||||
const questions = getElementsFromBlocks(surveyWithReplacedRecall.blocks);
|
||||
|
||||
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
|
||||
if (temp.length > 0) {
|
||||
if (shouldReverse) temp.reverse();
|
||||
result.push([...temp]);
|
||||
temp.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const processFinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
const temp: string[] = [];
|
||||
for (const question of questions) {
|
||||
if (isValidValue(response.data[question.id])) {
|
||||
flushTemp(temp, result);
|
||||
} else {
|
||||
temp.push(question.id);
|
||||
}
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const processUnfinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
const temp: string[] = [];
|
||||
for (let index = questions.length - 1; index >= 0; index--) {
|
||||
const question = questions[index];
|
||||
const hasNoData = !response.data[question.id];
|
||||
const shouldSkip =
|
||||
hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
|
||||
if (shouldSkip) {
|
||||
temp.push(question.id);
|
||||
} else {
|
||||
flushTemp(temp, result, true);
|
||||
}
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
|
||||
}, [response.finished, response.data, surveyWithReplacedRecall.blocks]);
|
||||
|
||||
const canResponseBeDeleted = response.finished
|
||||
? true
|
||||
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
|
||||
|
||||
const handleDeleteResponse = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.not_authorized"));
|
||||
}
|
||||
const result = await deleteResponseAction({ responseId: response.id, decrementQuotas });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
updateResponseList([response.id]);
|
||||
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFetchedResponses = async () => {
|
||||
const updatedResponse = await getResponseAction({ responseId: response.id });
|
||||
if (updatedResponse?.data) {
|
||||
updateResponse(response.id, updatedResponse.data as TResponseWithQuotas);
|
||||
}
|
||||
};
|
||||
|
||||
const bodyId = `response-card-body-${response.id}`;
|
||||
const showDeleteButton = !!user && !isReadOnly;
|
||||
|
||||
return (
|
||||
<SingleResponseCard
|
||||
survey={surveyWithReplacedRecall}
|
||||
response={response}
|
||||
user={user}
|
||||
environment={environment}
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
updateResponse={updateResponse}
|
||||
updateResponseList={updateResponseList}
|
||||
locale={locale}
|
||||
renderHeader={({ onDeleteClick, canResponseBeDeleted }) => (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100">
|
||||
<MessageSquareTextIcon className="h-4 w-4 text-slate-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-slate-500">{t("environments.contacts.survey_response_created")}</p>
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/${survey.id}/summary`}
|
||||
className="block truncate text-sm font-medium text-slate-700 hover:underline">
|
||||
{survey.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100">
|
||||
<MessageSquareTextIcon className="h-4 w-4 text-slate-600" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-500">
|
||||
<time className="px-1" dateTime={response.createdAt.toString()}>
|
||||
{timeSince(response.createdAt.toString(), locale)}
|
||||
</time>
|
||||
{showDeleteButton &&
|
||||
(canResponseBeDeleted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onDeleteClick}
|
||||
aria-label={t("environments.surveys.responses.delete_response")}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
className="text-slate-400"
|
||||
aria-label={t("environments.surveys.responses.delete_response")}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("environments.surveys.responses.this_response_is_in_progress")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.survey_response_created")}
|
||||
</p>
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/${survey.id}/summary`}
|
||||
className="block truncate text-sm font-medium text-slate-700 hover:underline">
|
||||
{survey.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-500">
|
||||
<time className="px-1" dateTime={response.createdAt.toString()}>
|
||||
{timeSince(response.createdAt.toString(), locale)}
|
||||
</time>
|
||||
{showDeleteButton &&
|
||||
(canResponseBeDeleted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
aria-label={t("environments.surveys.responses.delete_response")}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
className="text-slate-400"
|
||||
aria-label={t("environments.surveys.responses.delete_response")}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("environments.surveys.responses.this_response_is_in_progress")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={bodyId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("environments.contacts.collapse_response")
|
||||
: t("environments.contacts.expand_response")
|
||||
}>
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div id={bodyId}>
|
||||
<SingleResponseCardBody
|
||||
survey={surveyWithReplacedRecall}
|
||||
response={response}
|
||||
skippedQuestions={skippedQuestions}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<ResponseTagsWrapper
|
||||
key={response.id}
|
||||
environmentId={environment.id}
|
||||
responseId={response.id}
|
||||
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
|
||||
environmentTags={environmentTags}
|
||||
updateFetchedResponses={updateFetchedResponses}
|
||||
isReadOnly={isReadOnly}
|
||||
response={response}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={t("common.response")}
|
||||
onDelete={handleDeleteResponse}
|
||||
isDeleting={isDeleting}
|
||||
text={t("environments.surveys.responses.delete_response_confirmation")}>
|
||||
{hasQuotas && (
|
||||
<DecrementQuotasCheckbox
|
||||
title={t("environments.surveys.responses.delete_response_quotas")}
|
||||
checked={decrementQuotas}
|
||||
onCheckedChange={setDecrementQuotas}
|
||||
/>
|
||||
)}
|
||||
</DeleteDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ interface IconAction {
|
||||
onClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
disabled?: boolean;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
interface IconBarProps {
|
||||
@@ -17,30 +16,30 @@ interface IconBarProps {
|
||||
}
|
||||
|
||||
export const IconBar = ({ actions }: IconBarProps) => {
|
||||
const visibleActions = actions.filter((action) => action.isVisible);
|
||||
|
||||
if (visibleActions.length === 0) return null;
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center divide-x rounded-md border border-slate-300 bg-white"
|
||||
role="toolbar"
|
||||
aria-label="Action buttons">
|
||||
{visibleActions.map((action, index) => (
|
||||
<span key={`${action.tooltip}-${index}`}>
|
||||
<TooltipRenderer tooltipContent={action.tooltip}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-none hover:bg-slate-50"
|
||||
size="icon"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
aria-label={action.tooltip}>
|
||||
<action.icon className={action.iconClassName} />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</span>
|
||||
))}
|
||||
{actions
|
||||
.filter((action) => action.isVisible)
|
||||
.map((action, index) => (
|
||||
<span key={`${action.tooltip}-${index}`}>
|
||||
<TooltipRenderer tooltipContent={action.tooltip}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-none hover:bg-slate-50"
|
||||
size="icon"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
aria-label={action.tooltip}>
|
||||
<action.icon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ Formbricks applies request rate limits to protect against abuse and keep API usa
|
||||
Rate limits are scoped by identifier, depending on the endpoint:
|
||||
|
||||
- IP hash (for unauthenticated/client-side routes and public actions)
|
||||
- Environment ID (for public client storage upload abuse protection)
|
||||
- API key ID (for authenticated API calls)
|
||||
- User ID (for authenticated session-based calls and server actions)
|
||||
- Organization ID (for follow-up email dispatch)
|
||||
@@ -19,29 +20,30 @@ When a limit is exceeded, the API returns `429 Too Many Requests`.
|
||||
|
||||
These are the current limits for Management APIs:
|
||||
|
||||
| **Route Group** | **Limit** | **Window** | **Identifier** |
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
|
||||
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
|
||||
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
|
||||
| **Route Group** | **Limit** | **Window** | **Identifier** |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | ----------------------------- |
|
||||
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
|
||||
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
|
||||
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
|
||||
|
||||
## All Enforced Limits
|
||||
|
||||
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
|
||||
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
|
||||
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
|
||||
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
|
||||
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
|
||||
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
|
||||
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
|
||||
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
|
||||
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
|
||||
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
|
||||
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
|
||||
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
|
||||
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
|
||||
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
|
||||
| ------------------------------ | ------------ | ---------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
|
||||
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
|
||||
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
|
||||
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
|
||||
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
|
||||
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
|
||||
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
|
||||
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
|
||||
| `storage.uploadPerEnvironment` | 100 requests | 1 minute | Environment ID | Client storage upload only (`/api/v1/client/[environmentId]/storage` and the v2 re-export) |
|
||||
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
|
||||
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
|
||||
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
|
||||
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
|
||||
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
|
||||
|
||||
## Current Endpoint Exceptions
|
||||
|
||||
@@ -59,8 +61,8 @@ v1-style endpoints return:
|
||||
```json
|
||||
{
|
||||
"code": "too_many_requests",
|
||||
"message": "Maximum number of requests reached. Please try again later.",
|
||||
"details": {}
|
||||
"details": {},
|
||||
"message": "Maximum number of requests reached. Please try again later."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,4 +93,5 @@ After changing this value, restart the server.
|
||||
|
||||
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
|
||||
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
|
||||
- Client storage upload rate limits count signed upload URL issuance, not successful object creation in S3-compatible storage.
|
||||
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
|
||||
|
||||
@@ -44,10 +44,12 @@ checksums:
|
||||
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
|
||||
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
|
||||
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
|
||||
errors/file_input/invalid_file_name: 9f9a632eaf77ef92552f755f43e7b25d
|
||||
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
|
||||
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
|
||||
errors/file_input/placeholder_text: 15b61e390b6c5501d3e3b9da9f6c7930
|
||||
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
|
||||
errors/file_input/upload_service_unavailable: cd67a5c3ea1e6ff10636a7eec9f98740
|
||||
errors/file_input/uploading: baef62e2015a34d6747ed6e4192a27b1
|
||||
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
|
||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||
|
||||
Reference in New Issue
Block a user