Compare commits

...

6 Commits

Author SHA1 Message Date
Dhruwang cf2ef36ceb fix: add open_response_details translation under workspace.surveys.summary
The row aria-label was using the pre-migration environments namespace.
Use the workspace namespace to match every other t() call in the file,
add the key under workspace.surveys.summary in en-US.json, and regenerate
the 14 other locale files via lingo.dev (pnpm i18n).
2026-05-18 15:26:06 +05:30
Dhruwang aa040595c3 fix: address review feedback on response detail modal
- a11y: make OpenTextSummary rows keyboard-triggerable. Adds
  role="button", tabIndex={0}, aria-label, focus-visible ring, and an
  onKeyDown handler so Enter/Space open the modal — matching the
  pattern used in connectors-table-data-row.tsx.

- race: track the in-flight responseId in a ref inside
  ResponseSampleModal and bail out of .then/.catch/.finally branches
  when a newer responseId has been selected, preventing stale results
  from overwriting the current row's data.

- errors: surface action errors. Check getFormattedErrorMessage on
  both action results, toast.error and render the message inside the
  dialog instead of leaving the modal stuck on a loading spinner when
  the fetch fails or returns no data. Clear the error state on close.

- coverage: add unit tests for getResponseWithQuotas covering the
  happy path with/without screened-in quotas, the prisma select shape,
  input validation, the null-response path, and the database/generic
  error mappings.
2026-05-18 15:19:53 +05:30
Dhruwang 81c2bd365a fix: surface response quotas in lazy response detail modal
The modal cast getResponse's TResponse result to TResponseWithQuotas,
silently dropping quota info for the SingleResponseCard rendered inside
(hasQuotas was always false, hiding the decrement-quotas checkbox on
delete). Add a dedicated getResponseWithQuotas service that fetches
quotaLinks alongside the response, and have getResponseAction use it.

Keeps the public Management API v1 GET response shape unchanged and
avoids adding a quotaLinks join to the auth-helper hot paths
(getOrganizationIdFromResponseId / getWorkspaceIdFromResponseId).
2026-05-18 14:33:26 +05:30
Cursor Agent b26945698d fix: migrate response sample modal to workspace ids
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 07:05:16 +00:00
Cursor Agent 208d83eb08 feat: add lazy response detail modal on OpenTextSummary row click
Co-authored-by: johannes <johannes@formbricks.com>
2026-05-15 11:43:06 +00:00
Johannes 0a7482da0f Cursor: Apply local changes for cloud agent 2026-05-15 13:38:24 +02:00
24 changed files with 345 additions and 14 deletions
@@ -5,29 +5,35 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
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;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSummaryProps) => {
export const OpenTextSummary = ({
elementSummary,
survey,
locale,
isReadOnly,
}: Readonly<OpenTextSummaryProps>) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
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)
);
@@ -48,17 +54,31 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableRow
key={response.id}
role="button"
tabIndex={0}
aria-label={t("workspace.surveys.summary.open_response_details")}
className="cursor-pointer hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
onClick={() => setSelectedResponseId(response.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedResponseId(response.id);
}
}}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/workspaces/${workspace?.id}/contacts/${response.contact.id}`}>
href={`/workspaces/${survey.workspaceId}/contacts/${response.contact.id}`}
onClick={(e) => e.stopPropagation()}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
@@ -80,9 +100,12 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/4">
<TableCell className="w-1/6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -96,6 +119,14 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
)}
</div>
)}
<ResponseSampleModal
responseId={selectedResponseId}
onClose={() => setSelectedResponseId(null)}
survey={survey}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
);
};
@@ -0,0 +1,144 @@
"use client";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
getResponseAction,
getTagsByWorkspaceIdAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
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;
isReadOnly: boolean;
locale: TUserLocale;
}
export const ResponseSampleModal = ({
responseId,
onClose,
survey,
isReadOnly,
locale,
}: Readonly<ResponseSampleModalProps>) => {
const { t } = useTranslation();
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
const [tags, setTags] = useState<TTag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Cache fetched data per response ID to avoid re-fetching on re-open
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
// Track the in-flight request so stale resolutions can be ignored when the user
// switches rows quickly.
const latestRequestId = useRef<string | null>(null);
useEffect(() => {
if (!responseId) return;
const cached = cache.current.get(responseId);
if (cached) {
setResponse(cached.response);
setTags(cached.tags);
setErrorMessage(null);
return;
}
latestRequestId.current = responseId;
setIsLoading(true);
setResponse(null);
setErrorMessage(null);
Promise.all([
getResponseAction({ responseId }),
getTagsByWorkspaceIdAction({ workspaceId: survey.workspaceId }),
])
.then(([responseResult, tagsResult]) => {
// Discard if a newer request has started or the modal has been closed.
if (latestRequestId.current !== responseId) return;
const responseError = getFormattedErrorMessage(responseResult);
const tagsError = getFormattedErrorMessage(tagsResult);
const fetchedResponse = responseResult?.data ?? null;
const fetchedTags = tagsResult?.data ?? [];
if (responseError || tagsError || !fetchedResponse) {
const message = responseError || tagsError || t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
return;
}
const entry = { response: fetchedResponse, tags: fetchedTags };
cache.current.set(responseId, entry);
setResponse(entry.response);
setTags(entry.tags);
})
.catch(() => {
if (latestRequestId.current !== responseId) return;
const message = t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
})
.finally(() => {
if (latestRequestId.current !== responseId) return;
setIsLoading(false);
});
}, [responseId, survey.workspaceId, t]);
const handleOpenChange = (open: boolean) => {
if (!open) {
// Drop any in-flight request so it can't commit after close.
latestRequestId.current = null;
setErrorMessage(null);
onClose();
}
};
return (
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>{t("common.response")}</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>{t("common.response")}</DialogDescription>
</VisuallyHidden>
<DialogBody>
{isLoading ? (
<div className="py-12">
<LoadingSpinner />
</div>
) : errorMessage ? (
<div className="py-12 text-center text-sm text-slate-600">{errorMessage}</div>
) : response ? (
<SingleResponseCard
survey={survey}
response={response}
environmentTags={tags}
isReadOnly={isReadOnly}
locale={locale}
/>
) : null}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -41,9 +41,16 @@ interface SummaryListProps {
responseCount: number | null;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({
summary,
responseCount,
survey,
locale,
isReadOnly,
}: Readonly<SummaryListProps>) => {
const { workspace } = useWorkspaceContext();
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
@@ -116,6 +123,7 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
elementSummary={elementSummary}
survey={survey}
locale={locale}
isReadOnly={isReadOnly}
/>
);
}
@@ -49,6 +49,7 @@ interface SummaryPageProps {
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
isReadOnly: boolean;
}
export const SummaryPage = ({
@@ -57,7 +58,8 @@ export const SummaryPage = ({
locale,
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
isReadOnly,
}: Readonly<SummaryPageProps>) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
@@ -225,6 +227,7 @@ export const SummaryPage = ({
responseCount={surveySummary.meta.totalResponses}
survey={surveyMemoized}
locale={locale}
isReadOnly={isReadOnly}
/>
</>
);
@@ -22,7 +22,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const SurveyPage = async (props: { params: Promise<{ workspaceId: string; surveyId: string }> }) => {
const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: string; surveyId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
@@ -88,6 +88,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
isReadOnly={isReadOnly}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
+1
View File
@@ -3446,6 +3446,7 @@ checksums:
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/open_response_details: 0e5de115b5e605f68ea857cf8ef5533a
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
+37
View File
@@ -216,6 +216,43 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseWithQuotas = reactCache(
async (responseId: string): Promise<TResponseWithQuotas | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: {
...responseSelection,
quotaLinks: {
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
},
},
});
if (!responsePrisma) {
return null;
}
const { quotaLinks, ...rest } = responsePrisma;
return {
...mapResponsePrismaToResponse(rest),
quotas: quotaLinks.map((ql) => ql.quota),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
@@ -31,6 +31,7 @@ import {
getResponseBySingleUseId,
getResponseCountBySurveyId,
getResponseDownloadFile,
getResponseWithQuotas,
getResponsesByWorkspaceId,
responseSelection,
updateResponse,
@@ -170,6 +171,70 @@ describe("Tests for getResponse service", () => {
});
});
describe("Tests for getResponseWithQuotas service", () => {
describe("Happy Path", () => {
test("Returns the response with screened-in quotas", async () => {
prisma.response.findUnique.mockResolvedValue(mockResponseWithQuotas);
const result = await getResponseWithQuotas(mockResponseWithQuotas.id);
expect(result).toEqual({
...expectedResponseWithoutPerson,
quotas: mockResponseWithQuotas.quotaLinks.map(
(ql: { quota: { id: string; name: string } }) => ql.quota
),
});
});
test("Returns an empty quotas array when no quotaLinks are screened in", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toEqual({ ...expectedResponseWithoutPerson, quotas: [] });
});
test("Selects only screened-in quotaLinks", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
await getResponseWithQuotas(mockResponse.id);
const findUniqueCall = prisma.response.findUnique.mock.calls.at(-1)?.[0];
expect(findUniqueCall?.select?.quotaLinks).toEqual({
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
});
});
});
describe("Sad Path", () => {
testInputValidation(getResponseWithQuotas, "123#");
test("Returns null when no response is found", async () => {
prisma.response.findUnique.mockResolvedValue(null);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toBeNull();
});
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.response.findUnique.mockRejectedValue(errToThrow);
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow(DatabaseError);
});
test("Rethrows generic errors", async () => {
prisma.response.findUnique.mockRejectedValue(new Error("boom"));
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow("boom");
});
});
});
describe("Tests for getSurveySummary service", () => {
describe("Happy Path", () => {
test("Returns a summary of the survey responses", async () => {
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
"open_response_details": "Details zu offenen Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Gesamt",
"promoters": "Promotoren",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"open_response_details": "Open response details",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"open_response_details": "Detalles de respuesta abierta",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
"open_response_details": "Détails des réponses ouvertes",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
"open_response_details": "Nyitott válasz részletei",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
"promoters": "Népszerűsítők",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"nps_promoters_tooltip": "回答者の{percentage}%が9または10の評価をしました(NPSプロモーター)。",
"open_response_details": "自由回答の詳細",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"nps_promoters_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 9 of 10 (NPS promoters).",
"open_response_details": "Details open antwoorden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
"promoters": "Promoters",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos entrevistados deram uma nota de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes das respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos inquiridos deram uma classificação de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes de respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Nicio impresie de la contactele identificate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"nps_promoters_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 9 sau 10 (promotori NPS).",
"open_response_details": "Detalii răspunsuri deschise",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Нет показов от идентифицированных контактов",
"no_responses_found": "Ответы не найдены",
"nps_promoters_tooltip": "{percentage}% респондентов дали оценку 9 или 10 (промоутеры NPS).",
"open_response_details": "Детали открытых ответов",
"other_values_found": "Найдены другие значения",
"overall": "В целом",
"promoters": "Сторонники",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Inga visningar från identifierade kontakter",
"no_responses_found": "Inga svar hittades",
"nps_promoters_tooltip": "{percentage}% av respondenterna gav ett betyg på 9 eller 10 (NPS-ambassadörer).",
"open_response_details": "Detaljer för öppna svar",
"other_values_found": "Andra värden hittades",
"overall": "Övergripande",
"promoters": "Ambassadörer",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "Tanımlanmış kişilerden gösterim yok",
"no_responses_found": "Yanıt bulunamadı",
"nps_promoters_tooltip": "Yanıt verenlerin %{percentage}'si 9 veya 10 puan verdi (NPS tavsiye edenler).",
"open_response_details": "Açık yanıt detayları",
"other_values_found": "Diğer değerler bulundu",
"overall": "Genel",
"promoters": "Tavsiye edenler",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "没有已识别联系人的展示次数",
"no_responses_found": "未找到响应",
"nps_promoters_tooltip": "{percentage}% 的受访者给出了 9 或 10 分的评价(NPS 推荐者)。",
"open_response_details": "开放式回答详情",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
+1
View File
@@ -3597,6 +3597,7 @@
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
"no_responses_found": "找不到回應",
"nps_promoters_tooltip": "{percentage}% 的受訪者給予 9 或 10 分評價(NPS 推薦者)。",
"open_response_details": "開放式回覆詳情",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
@@ -3,8 +3,8 @@
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 { deleteResponse, getResponseWithQuotas } from "@/lib/response/service";
import { createTag, getTagsByWorkspaceId } 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";
@@ -173,6 +173,32 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
})
);
const ZGetTagsByWorkspaceIdAction = z.object({
workspaceId: ZId,
});
export const getTagsByWorkspaceIdAction = authenticatedActionClient
.inputSchema(ZGetTagsByWorkspaceIdAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission: "read",
workspaceId: parsedInput.workspaceId,
},
],
});
return await getTagsByWorkspaceId(parsedInput.workspaceId);
});
const ZGetResponseAction = z.object({
responseId: ZId,
});
@@ -196,5 +222,5 @@ export const getResponseAction = authenticatedActionClient
],
});
return await getResponse(parsedInput.responseId);
return await getResponseWithQuotas(parsedInput.responseId);
});