diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 051ac1e527..776e8376d0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -2,12 +2,14 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; +import { customAlphabet } from "nanoid"; import { getServerSession } from "next-auth"; import { authOptions } from "@formbricks/lib/authOptions"; import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails"; import { canUserAccessSurvey } from "@formbricks/lib/survey/auth"; -import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; +import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; +import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; type TSendEmailActionArgs = { to: string; @@ -35,6 +37,58 @@ export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArg return await sendEmbedSurveyPreviewEmail(to, subject, html); }; +export async function generateResultShareUrlAction(surveyId: string): Promise { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId); + if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized"); + + const survey = await getSurvey(surveyId); + if (!survey?.id) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + const resultShareKey = customAlphabet( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + 20 + )(); + + await updateSurvey({ ...survey, resultShareKey }); + + return resultShareKey; +} + +export async function getResultShareUrlAction(surveyId: string): Promise { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId); + if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized"); + + const survey = await getSurvey(surveyId); + if (!survey?.id) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + return survey.resultShareKey; +} + +export async function deleteResultShareUrlAction(surveyId: string): Promise { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId); + if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized"); + + const survey = await getSurvey(surveyId); + if (!survey?.id) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + await updateSurvey({ ...survey, resultShareKey: null }); +} + export const getEmailHtmlAction = async (surveyId: string) => { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx index 6baed4a67f..450db8739d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx @@ -1,17 +1,30 @@ "use client"; -import { ShareIcon } from "@heroicons/react/24/outline"; -import clsx from "clsx"; +import { + deleteResultShareUrlAction, + generateResultShareUrlAction, + getResultShareUrlAction, +} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; +import { LinkIcon } from "@heroicons/react/24/outline"; +import { DownloadIcon } from "lucide-react"; import { useState } from "react"; +import { useEffect } from "react"; +import toast from "react-hot-toast"; import { TProduct } from "@formbricks/types/product"; import { TSurvey } from "@formbricks/types/surveys"; import { TUser } from "@formbricks/types/user"; -import { Button } from "@formbricks/ui/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; import ShareEmbedSurvey from "./ShareEmbedSurvey"; +import ShareSurveyResults from "./ShareSurveyResults"; -interface LinkSurveyShareButtonProps { +interface SurveyShareButtonProps { survey: TSurvey; className?: string; webAppUrl: string; @@ -19,28 +32,83 @@ interface LinkSurveyShareButtonProps { user: TUser; } -export default function LinkSurveyShareButton({ - survey, - className, - webAppUrl, - product, - user, -}: LinkSurveyShareButtonProps) { +export default function SurveyShareButton({ survey, webAppUrl, product, user }: SurveyShareButtonProps) { const [showLinkModal, setShowLinkModal] = useState(false); + const [showResultsLinkModal, setShowResultsLinkModal] = useState(false); + + const [showPublishModal, setShowPublishModal] = useState(false); + const [surveyUrl, setSurveyUrl] = useState(""); + + const handlePublish = async () => { + const key = await generateResultShareUrlAction(survey.id); + setSurveyUrl(webAppUrl + "/share/" + key); + setShowPublishModal(true); + }; + + const handleUnpublish = () => { + deleteResultShareUrlAction(survey.id) + .then(() => { + toast.success("Survey Unpublished successfully"); + setShowPublishModal(false); + setShowLinkModal(false); + }) + .catch((error) => { + toast.error(`Error: ${error.message}`); + }); + }; + + useEffect(() => { + async function fetchSharingKey() { + const sharingKey = await getResultShareUrlAction(survey.id); + if (sharingKey) { + setSurveyUrl(webAppUrl + "/share/" + sharingKey); + setShowPublishModal(true); + } + } + + fetchSharingKey(); + }, [survey.id, webAppUrl]); + + useEffect(() => { + if (showResultsLinkModal) { + setShowLinkModal(false); + } + }, [showResultsLinkModal]); return ( <> - + + +
+
+ Share + +
+ +
+
+ + {survey.type === "link" && ( + { + setShowLinkModal(true); + }}> +

Share Survey

+
+ )} + { + setShowResultsLinkModal(true); + }}> +

Publish Results

+
+
+
+ {showLinkModal && ( )} + {showResultsLinkModal && ( + + )} ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx new file mode 100644 index 0000000000..5c97d1da61 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { CheckCircleIcon, GlobeEuropeAfricaIcon } from "@heroicons/react/24/solid"; +import { Clipboard } from "lucide-react"; +import { toast } from "react-hot-toast"; + +import { Button } from "@formbricks/ui/Button"; +import { Dialog, DialogContent } from "@formbricks/ui/Dialog"; + +interface ShareEmbedSurveyProps { + open: boolean; + setOpen: React.Dispatch>; + handlePublish: () => void; + handleUnpublish: () => void; + showPublishModal: boolean; + surveyUrl: string; +} +export default function ShareSurveyResults({ + open, + setOpen, + handlePublish, + handleUnpublish, + showPublishModal, + surveyUrl, +}: ShareEmbedSurveyProps) { + return ( + { + setOpen(open); + }}> + {showPublishModal && surveyUrl ? ( + +
+ +
+ Your survey results are public on the web. +
+
+ Your survey results are shared with anyone who has the link. +
+
+ The results will not be indexed by search engines. +
+ +
+
+ + {surveyUrl} + +
+ +
+ +
+ + + +
+
+
+ ) : ( + +
+ +
+ Publish Results to web +
+
+ Your survey results are shared with anyone who has the link. +
+
+ The results will not be indexed by search engines. +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx index 9fc82b6db3..be09161afe 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx @@ -1,6 +1,6 @@ "use client"; -import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton"; +import SurveyShareButton from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton"; import SuccessMessage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; @@ -14,6 +14,7 @@ import { TMembershipRole } from "@formbricks/types/memberships"; import { TProduct } from "@formbricks/types/product"; import { TSurvey } from "@formbricks/types/surveys"; import { TUser } from "@formbricks/types/user"; +import { Badge } from "@formbricks/ui/Badge"; import { Button } from "@formbricks/ui/Button"; import { DropdownMenu, @@ -53,16 +54,18 @@ const SummaryHeader = ({ const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null; const isStatusChangeDisabled = (isCloseOnDateEnabled && closeOnDate && closeOnDate < new Date()) ?? false; const { isViewer } = getAccessFlags(membershipRole); + return (
-

{survey.name}

+
+

{survey.name}

+ {survey.resultShareKey && } +
{product.name}
- {survey.type === "link" && ( - - )} + {!isViewer && (environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? ( @@ -88,7 +91,7 @@ const SummaryHeader = ({ {survey.type === "link" && ( <> - @@ -23,8 +23,13 @@ export default async function AppLayout({ children }) { <> - - + {session ? ( + <> + + + + ) : null} + {children} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs.tsx new file mode 100644 index 0000000000..a02d75d220 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs.tsx @@ -0,0 +1,60 @@ +import { cn } from "@formbricks/lib/cn"; +import { PresentationChartLineIcon, InboxStackIcon } from "@heroicons/react/24/solid"; +import Link from "next/link"; +import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; + +interface SurveyResultsTabProps { + activeId: string; + environmentId: string; + surveyId: string; + sharingKey: string; +} + +export default function SurveyResultsTab({ + activeId, + environmentId, + surveyId, + sharingKey, +}: SurveyResultsTabProps) { + const tabs = [ + { + id: "summary", + label: "Summary", + icon: , + href: `/share/${sharingKey}/summary?referer=true`, + }, + { + id: "responses", + label: "Responses", + icon: , + href: `/share/${sharingKey}/responses?referer=true`, + }, + ]; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/actions.ts b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/actions.ts new file mode 100644 index 0000000000..321c3cfa57 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/actions.ts @@ -0,0 +1,38 @@ +"use server"; +import { createTag } from "@formbricks/lib/tag/service"; +import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getServerSession } from "next-auth"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth"; + +export const createTagAction = async (environmentId: string, tagName: string) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + return await createTag(environmentId, tagName); +}; + +export const createTagToResponeAction = async (responseId: string, tagId: string) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + return await addTagToRespone(responseId, tagId); +}; + +export const deleteTagOnResponseAction = async (responseId: string, tagId: string) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + return await deleteTagOnResponse(responseId, tagId); +}; diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseNote.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseNote.tsx new file mode 100644 index 0000000000..6e6c982e10 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseNote.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { PlusIcon } from "@heroicons/react/24/solid"; +import clsx from "clsx"; +import { Maximize2Icon, Minimize2Icon } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; + +import { cn } from "@formbricks/lib/cn"; +import { timeSince } from "@formbricks/lib/time"; +import { TResponseNote } from "@formbricks/types/responses"; + +interface ResponseNotesProps { + responseId: string; + notes: TResponseNote[]; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +export default function ResponseNotes({ notes, isOpen, setIsOpen }: ResponseNotesProps) { + const divRef = useRef(null); + + useEffect(() => { + if (divRef.current) { + divRef.current.scrollTop = divRef.current.scrollHeight; + } + }, [notes]); + + const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]); + + return ( +
{ + if (!isOpen) setIsOpen(true); + }}> + {!isOpen ? ( +
+
+ {!unresolvedNotes.length ? ( +
+
+

Note

+
+
+ ) : ( +
+ +
+ )} +
+ {!unresolvedNotes.length ? ( +
+ + + +
+ ) : null} +
+ ) : ( +
+
+
+
+

Note

+
+ +
+
+
+ {unresolvedNotes.map((note) => ( +
+ + {note.user.name} + + {note.isEdited && ( + {"(edited)"} + )} + +
+ {note.text} +
+
+ ))} +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx new file mode 100644 index 0000000000..42f809cdb3 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; +import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs"; +import ResponseTimeline from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline"; +import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader"; +import { getFilterResponses } from "@/app/lib/surveys/surveys"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import ContentWrapper from "@formbricks/ui/ContentWrapper"; + +interface ResponsePageProps { + environment: TEnvironment; + survey: TSurvey; + surveyId: string; + responses: TResponse[]; + webAppUrl: string; + product: TProduct; + sharingKey: string; + environmentTags: TTag[]; + responsesPerPage: number; +} + +const ResponsePage = ({ + environment, + survey, + surveyId, + responses, + product, + sharingKey, + environmentTags, + responsesPerPage, +}: ResponsePageProps) => { + const { selectedFilter, dateRange, resetState } = useResponseFilter(); + + const searchParams = useSearchParams(); + + useEffect(() => { + if (!searchParams?.get("referer")) { + resetState(); + } + }, [searchParams]); + + // get the filtered array when the selected filter value changes + const filterResponses: TResponse[] = useMemo(() => { + return getFilterResponses(responses, selectedFilter, survey, dateRange); + }, [selectedFilter, responses, survey, dateRange]); + return ( + + + + + + + ); +}; + +export default ResponsePage; diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx new file mode 100644 index 0000000000..1ee9bc759f --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx @@ -0,0 +1,91 @@ +"use client"; + +import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys"; +import { useEffect, useRef, useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; +import SingleResponseCard from "@formbricks/ui/SingleResponseCard"; + +interface ResponseTimelineProps { + environment: TEnvironment; + surveyId: string; + responses: TResponse[]; + survey: TSurvey; + environmentTags: TTag[]; + responsesPerPage: number; +} + +export default function ResponseTimeline({ + environment, + responses, + survey, + environmentTags, + responsesPerPage, +}: ResponseTimelineProps) { + const [displayedResponses, setDisplayedResponses] = useState([]); + const loadingRef = useRef(null); + + useEffect(() => { + setDisplayedResponses(responses.slice(0, responsesPerPage)); + }, [responses]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setDisplayedResponses((prevResponses) => [ + ...prevResponses, + ...responses.slice(prevResponses.length, prevResponses.length + responsesPerPage), + ]); + } + }, + { threshold: 0.8 } + ); + + if (loadingRef.current) { + observer.observe(loadingRef.current); + } + + return () => { + if (loadingRef.current) { + observer.unobserve(loadingRef.current); + } + }; + }, [responses]); + + return ( +
+ {survey.type === "web" && displayedResponses.length === 0 && !environment.widgetSetupCompleted ? ( + + ) : displayedResponses.length === 0 ? ( + + ) : ( +
+ {displayedResponses.map((response) => { + return ( +
+ +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/page.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/page.tsx new file mode 100644 index 0000000000..2453bbc67f --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/page.tsx @@ -0,0 +1,57 @@ +import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data"; +import ResponsePage from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage"; +import { getResultShareUrlSurveyAction } from "@/app/(app)/share/[sharingKey]/action"; +import { notFound } from "next/navigation"; + +import { RESPONSES_PER_PAGE, REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; + +export const revalidate = REVALIDATION_INTERVAL; + +export default async function Page({ params }) { + const surveyId = await getResultShareUrlSurveyAction(params.sharingKey); + + if (!surveyId) { + return notFound(); + } + + const survey = await getSurvey(surveyId); + + if (!survey) { + throw new Error("Survey not found"); + } + + const [{ responses }, environment] = await Promise.all([ + getAnalysisData(survey.id, survey.environmentId), + getEnvironment(survey.environmentId), + ]); + + if (!environment) { + throw new Error("Environment not found"); + } + const product = await getProductByEnvironmentId(environment.id); + if (!product) { + throw new Error("Product not found"); + } + + const tags = await getTagsByEnvironmentId(environment.id); + + return ( + <> + + + ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx new file mode 100644 index 0000000000..4114a6da33 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; +import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList"; +import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata"; +import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; +import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs"; +import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader"; +import { getFilterResponses } from "@/app/lib/surveys/surveys"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import ContentWrapper from "@formbricks/ui/ContentWrapper"; + +interface SummaryPageProps { + environment: TEnvironment; + survey: TSurvey; + surveyId: string; + responses: TResponse[]; + product: TProduct; + sharingKey: string; + environmentTags: TTag[]; + displayCount: number; + responsesPerPage: number; +} + +const SummaryPage = ({ + environment, + survey, + surveyId, + responses, + product, + sharingKey, + environmentTags, + displayCount, + responsesPerPage: openTextResponsesPerPage, +}: SummaryPageProps) => { + const { selectedFilter, dateRange, resetState } = useResponseFilter(); + const [showDropOffs, setShowDropOffs] = useState(false); + const searchParams = useSearchParams(); + + useEffect(() => { + if (!searchParams?.get("referer")) { + resetState(); + } + }, [searchParams]); + + // get the filtered array when the selected filter value changes + const filterResponses: TResponse[] = useMemo(() => { + return getFilterResponses(responses, selectedFilter, survey, dateRange); + }, [selectedFilter, responses, survey, dateRange]); + + return ( + + + + + + {showDropOffs && } + + + ); +}; + +export default SummaryPage; diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/page.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/page.tsx new file mode 100644 index 0000000000..dcaf4352ce --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/page.tsx @@ -0,0 +1,58 @@ +import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data"; +import SummaryPage from "@/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage"; +import { getResultShareUrlSurveyAction } from "@/app/(app)/share/[sharingKey]/action"; +import { notFound } from "next/navigation"; + +import { REVALIDATION_INTERVAL, TEXT_RESPONSES_PER_PAGE } from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; + +export const revalidate = REVALIDATION_INTERVAL; + +export default async function Page({ params }) { + const surveyId = await getResultShareUrlSurveyAction(params.sharingKey); + + if (!surveyId) { + return notFound(); + } + + const survey = await getSurvey(surveyId); + + if (!survey) { + throw new Error("Survey not found"); + } + + const [{ responses, displayCount }, environment] = await Promise.all([ + getAnalysisData(survey.id, survey.environmentId), + getEnvironment(survey.environmentId), + ]); + + if (!environment) { + throw new Error("Environment not found"); + } + + const product = await getProductByEnvironmentId(environment.id); + if (!product) { + throw new Error("Product not found"); + } + + const tags = await getTagsByEnvironmentId(environment.id); + + return ( + <> + + + ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/action.ts b/apps/web/app/(app)/share/[sharingKey]/action.ts new file mode 100644 index 0000000000..bab2488c7b --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/action.ts @@ -0,0 +1,7 @@ +"use server"; + +import { getSurveyByResultShareKey } from "@formbricks/lib/survey/service"; + +export async function getResultShareUrlSurveyAction(key: string): Promise { + return getSurveyByResultShareKey(key); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx b/apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx new file mode 100755 index 0000000000..5a2a26ee59 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { + DateRange, + useResponseFilter, +} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import ResponseFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; +import { fetchFile } from "@/app/lib/fetchFile"; +import { generateQuestionAndFilterOptions, getTodayDate } from "@/app/lib/surveys/surveys"; +import { createId } from "@paralleldrive/cuid2"; +import { differenceInDays, format, subDays } from "date-fns"; +import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { getTodaysDateFormatted } from "@formbricks/lib/time"; +import useClickOutside from "@formbricks/lib/useClickOutside"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import { Calendar } from "@formbricks/ui/Calendar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; + +enum DateSelected { + FROM = "from", + TO = "to", +} + +enum FilterDownload { + ALL = "all", + FILTER = "filter", +} + +enum FilterDropDownLabels { + ALL_TIME = "All time", + LAST_7_DAYS = "Last 7 days", + LAST_30_DAYS = "Last 30 days", + CUSTOM_RANGE = "Custom range...", +} + +interface CustomFilterProps { + environmentTags: TTag[]; + survey: TSurvey; + responses: TResponse[]; + totalResponses: TResponse[]; +} + +const getDifferenceOfDays = (from, to) => { + const days = differenceInDays(to, from); + if (days === 7) { + return FilterDropDownLabels.LAST_7_DAYS; + } else if (days === 30) { + return FilterDropDownLabels.LAST_30_DAYS; + } else { + return FilterDropDownLabels.CUSTOM_RANGE; + } +}; + +const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: CustomFilterProps) => { + const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter(); + const [filterRange, setFilterRange] = useState( + dateRange.from && dateRange.to + ? getDifferenceOfDays(dateRange.from, dateRange.to) + : FilterDropDownLabels.ALL_TIME + ); + const [selectingDate, setSelectingDate] = useState(DateSelected.FROM); + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState(false); + const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState(false); + const [hoveredRange, setHoveredRange] = useState(null); + + // when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options + useEffect(() => { + const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions( + survey, + totalResponses, + environmentTags + ); + setSelectedOptions({ questionFilterOptions, questionOptions }); + }, [totalResponses, survey, setSelectedOptions, environmentTags]); + + const datePickerRef = useRef(null); + + const getMatchQandA = (responses: TResponse[], survey: TSurvey) => { + if (survey && responses) { + // Create a mapping of question IDs to their headlines + const questionIdToHeadline = {}; + survey.questions.forEach((question) => { + questionIdToHeadline[question.id] = question.headline; + }); + + // Replace question IDs with question headlines in response data + const updatedResponses = responses.map((response) => { + const updatedResponse: Array<{ + id: string; + question: string; + answer: string; + type: string; + scale?: "number" | "star" | "smiley"; + range?: number; + }> = []; // Specify the type of updatedData + // iterate over survey questions and build the updated response + for (const question of survey.questions) { + const answer = response.data[question.id]; + if (answer) { + updatedResponse.push({ + id: createId(), + question: question.headline, + type: question.type, + scale: question.scale, + range: question.range, + answer: answer as string, + }); + } + } + return { ...response, responses: updatedResponse }; + }); + + const updatedResponsesWithTags = updatedResponses.map((response) => ({ + ...response, + tags: response.tags?.map((tag) => tag), + })); + + return updatedResponsesWithTags; + } + return []; + }; + + const downloadFileName = useMemo(() => { + if (survey) { + const formattedDateString = getTodaysDateFormatted("_"); + return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase(); + } + + return "my_survey_responses"; + }, [survey]); + + function extracMetadataKeys(obj, parentKey = "") { + let keys: string[] = []; + + for (let key in obj) { + if (typeof obj[key] === "object" && obj[key] !== null) { + keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - ")); + } else { + keys.push(parentKey + key); + } + } + + return keys; + } + + const downloadResponses = useCallback( + async (filter: FilterDownload, filetype: "csv" | "xlsx") => { + const downloadResponse = filter === FilterDownload.ALL ? totalResponses : responses; + const questionNames = survey.questions?.map((question) => question.headline); + const hiddenFieldIds = survey.hiddenFields.fieldIds; + const hiddenFieldResponse = {}; + let metaDataFields = extracMetadataKeys(downloadResponse[0].meta); + const userAttributes = ["Init Attribute 1", "Init Attribute 2"]; + const matchQandA = getMatchQandA(downloadResponse, survey); + const jsonData = matchQandA.map((response) => { + const basicInfo = { + "Response ID": response.id, + Timestamp: response.createdAt, + Finished: response.finished, + "Survey ID": response.surveyId, + "Formbricks User ID": response.person?.id ?? "", + }; + const metaDataKeys = extracMetadataKeys(response.meta); + let metaData = {}; + metaDataKeys.forEach((key) => { + if (!metaDataFields.includes(key)) metaDataFields.push(key); + if (response.meta) { + if (key.includes("-")) { + const nestedKeyArray = key.split("-"); + metaData[key] = response.meta[nestedKeyArray[0].trim()][nestedKeyArray[1].trim()] ?? ""; + } else { + metaData[key] = response.meta[key] ?? ""; + } + } + }); + + const personAttributes = response.personAttributes; + if (hiddenFieldIds && hiddenFieldIds.length > 0) { + hiddenFieldIds.forEach((hiddenFieldId) => { + hiddenFieldResponse[hiddenFieldId] = response.data[hiddenFieldId] ?? ""; + }); + } + const fileResponse = { ...basicInfo, ...metaData, ...personAttributes, ...hiddenFieldResponse }; + // Map each question name to its corresponding answer + questionNames.forEach((questionName: string) => { + const matchingQuestion = response.responses.find((question) => question.question === questionName); + let transformedAnswer = ""; + if (matchingQuestion) { + const answer = matchingQuestion.answer; + if (Array.isArray(answer)) { + transformedAnswer = answer.join("; "); + } else { + transformedAnswer = answer; + } + } + fileResponse[questionName] = matchingQuestion ? transformedAnswer : ""; + }); + + return fileResponse; + }); + + // Fields which will be used as column headers in the file + const fields = [ + "Response ID", + "Timestamp", + "Finished", + "Survey ID", + "Formbricks User ID", + ...metaDataFields, + ...questionNames, + ...(hiddenFieldIds ?? []), + ...(survey.type === "web" ? userAttributes : []), + ]; + + let response; + + try { + response = await fetchFile( + { + json: jsonData, + fields, + fileName: downloadFileName, + }, + filetype + ); + } catch (err) { + toast.error(`Error downloading ${filetype === "csv" ? "CSV" : "Excel"}`); + return; + } + + let blob: Blob; + if (filetype === "csv") { + blob = new Blob([response.fileResponse], { type: "text/csv;charset=utf-8;" }); + } else if (filetype === "xlsx") { + const binaryString = atob(response["fileResponse"]); + const byteArray = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + byteArray[i] = binaryString.charCodeAt(i); + } + blob = new Blob([byteArray], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + } else { + throw new Error(`Unsupported filetype: ${filetype}`); + } + + const downloadUrl = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = `${downloadFileName}.${filetype}`; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + + URL.revokeObjectURL(downloadUrl); + }, + [downloadFileName, responses, totalResponses, survey] + ); + + const handleDateHoveredChange = (date: Date) => { + if (selectingDate === DateSelected.FROM) { + const startOfRange = new Date(date); + startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day + + // Check if the selected date is after the current 'to' date + if (startOfRange > dateRange?.to!) { + return; + } else { + setHoveredRange({ from: startOfRange, to: dateRange.to }); + } + } else { + const endOfRange = new Date(date); + endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day + + // Check if the selected date is before the current 'from' date + if (endOfRange < dateRange?.from!) { + return; + } else { + setHoveredRange({ from: dateRange.from, to: endOfRange }); + } + } + }; + + const handleDateChange = (date: Date) => { + if (selectingDate === DateSelected.FROM) { + const startOfRange = new Date(date); + startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day + + // Check if the selected date is after the current 'to' date + if (startOfRange > dateRange?.to!) { + const nextDay = new Date(startOfRange); + nextDay.setDate(nextDay.getDate() + 1); + nextDay.setHours(23, 59, 59, 999); + setDateRange({ from: startOfRange, to: nextDay }); + } else { + setDateRange((prevData) => ({ from: startOfRange, to: prevData.to })); + } + setSelectingDate(DateSelected.TO); + } else { + const endOfRange = new Date(date); + endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day + + // Check if the selected date is before the current 'from' date + if (endOfRange < dateRange?.from!) { + const previousDay = new Date(endOfRange); + previousDay.setDate(previousDay.getDate() - 1); + previousDay.setHours(0, 0, 0, 0); // Set to the start of the selected day + setDateRange({ from: previousDay, to: endOfRange }); + } else { + setDateRange((prevData) => ({ from: prevData?.from, to: endOfRange })); + } + setIsDatePickerOpen(false); + setSelectingDate(DateSelected.FROM); + } + }; + + const handleDatePickerClose = () => { + setIsDatePickerOpen(false); + setSelectingDate(DateSelected.FROM); + }; + + useClickOutside(datePickerRef, () => handleDatePickerClose()); + + return ( + <> +
+
+ + { + value && handleDatePickerClose(); + setIsFilterDropDownOpen(value); + }}> + +
+ + {filterRange === FilterDropDownLabels.CUSTOM_RANGE + ? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${ + dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date" + }` + : filterRange} + + {isFilterDropDownOpen ? ( + + ) : ( + + )} +
+
+ + { + setFilterRange(FilterDropDownLabels.ALL_TIME); + setDateRange({ from: undefined, to: getTodayDate() }); + }}> +

All time

+
+ { + setFilterRange(FilterDropDownLabels.LAST_7_DAYS); + setDateRange({ from: subDays(new Date(), 7), to: getTodayDate() }); + }}> +

Last 7 days

+
+ { + setFilterRange(FilterDropDownLabels.LAST_30_DAYS); + setDateRange({ from: subDays(new Date(), 30), to: getTodayDate() }); + }}> +

Last 30 days

+
+ { + setIsDatePickerOpen(true); + setFilterRange(FilterDropDownLabels.CUSTOM_RANGE); + setSelectingDate(DateSelected.FROM); + }}> +

Custom range...

+
+
+
+ { + value && handleDatePickerClose(); + setIsDownloadDropDownOpen(value); + }}> + +
+
+ Download + {isDownloadDropDownOpen ? ( + + ) : ( + + )} +
+ +
+
+ + { + downloadResponses(FilterDownload.ALL, "csv"); + }}> +

All responses (CSV)

+
+ { + downloadResponses(FilterDownload.ALL, "xlsx"); + }}> +

All responses (Excel)

+
+ { + downloadResponses(FilterDownload.FILTER, "csv"); + }}> +

Current selection (CSV)

+
+ { + downloadResponses(FilterDownload.FILTER, "xlsx"); + }}> +

Current selection (Excel)

+
+
+
+
+ {isDatePickerOpen && ( +
+ handleDateChange(date)} + onDayMouseEnter={handleDateHoveredChange} + onDayMouseLeave={() => setHoveredRange(null)} + classNames={{ + day_today: "hover:bg-slate-200 bg-white", + }} + /> +
+ )} +
+ + ); +}; + +export default CustomFilter; diff --git a/apps/web/app/(app)/share/[sharingKey]/components/SummaryHeader.tsx b/apps/web/app/(app)/share/[sharingKey]/components/SummaryHeader.tsx new file mode 100644 index 0000000000..d55fada362 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/components/SummaryHeader.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; + +interface SummaryHeaderProps { + survey: TSurvey; + product: TProduct; +} +const SummaryHeader = ({ survey, product }: SummaryHeaderProps) => { + return ( +
+
+

{survey.name}

+ {product.name} +
+
+ ); +}; + +export default SummaryHeader; diff --git a/apps/web/app/(app)/share/[sharingKey]/layout.tsx b/apps/web/app/(app)/share/[sharingKey]/layout.tsx new file mode 100644 index 0000000000..480eee48cb --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/layout.tsx @@ -0,0 +1,14 @@ +import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +export default async function EnvironmentLayout({ children }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/not-found.tsx b/apps/web/app/(app)/share/[sharingKey]/not-found.tsx new file mode 100644 index 0000000000..07d8aecfb6 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/not-found.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; + +import { Button } from "@formbricks/ui/Button"; + +export default function NotFound() { + return ( + <> +
+

404

+

Page not found

+

+ Sorry, we couldn’t find the responses sharing ID you’re looking for. +

+ + + +
+ + ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/page.tsx b/apps/web/app/(app)/share/[sharingKey]/page.tsx new file mode 100644 index 0000000000..24ce0ffb69 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function EnvironmentPage({ params }) { + return redirect(`/share/${params.sharingKey}/summary`); +} diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index c06fae8b80..98783982dc 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -148,6 +148,76 @@ export const generateQuestionAndFilterOptions = ( return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] }; }; +export const generateQuestionAndFilterOptionsForResponseSharing = ( + survey: TSurvey, + responses: TResponse[] +): { + questionOptions: QuestionOptions[]; + questionFilterOptions: QuestionFilterOptions[]; +} => { + let questionOptions: any = []; + let questionFilterOptions: any = []; + + let questionsOptions: any = []; + + survey.questions.forEach((q) => { + if (Object.keys(conditionOptions).includes(q.type)) { + questionsOptions.push({ + label: q.headline, + questionType: q.type, + type: OptionsType.QUESTIONS, + id: q.id, + }); + } + }); + questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }]; + survey.questions.forEach((q) => { + if (Object.keys(conditionOptions).includes(q.type)) { + if ( + q.type === TSurveyQuestionType.MultipleChoiceMulti || + q.type === TSurveyQuestionType.MultipleChoiceSingle + ) { + questionFilterOptions.push({ + type: q.type, + filterOptions: conditionOptions[q.type], + filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""], + id: q.id, + }); + } else { + questionFilterOptions.push({ + type: q.type, + filterOptions: conditionOptions[q.type], + filterComboBoxOptions: filterOptions[q.type], + id: q.id, + }); + } + } + }); + + const attributes = getPersonAttributes(responses); + if (attributes) { + questionOptions = [ + ...questionOptions, + { + header: OptionsType.ATTRIBUTES, + option: Object.keys(attributes).map((a) => { + return { label: a, type: OptionsType.ATTRIBUTES, id: a }; + }), + }, + ]; + Object.keys(attributes).forEach((a) => { + questionFilterOptions.push({ + type: "Attributes", + filterOptions: conditionOptions.userAttributes, + filterComboBoxOptions: attributes[a], + id: a, + }); + }); + } + + return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] }; +}; + // get the filtered responses export const getFilterResponses = ( responses: TResponse[], diff --git a/apps/web/app/middleware/bucket.ts b/apps/web/app/middleware/bucket.ts index efd8265516..d49f9376da 100644 --- a/apps/web/app/middleware/bucket.ts +++ b/apps/web/app/middleware/bucket.ts @@ -1,6 +1,11 @@ import rateLimit from "@/app/middleware/rateLimit"; -import { CLIENT_SIDE_API_RATE_LIMIT, LOGIN_RATE_LIMIT, SIGNUP_RATE_LIMIT } from "@formbricks/lib/constants"; +import { + CLIENT_SIDE_API_RATE_LIMIT, + LOGIN_RATE_LIMIT, + SHARE_RATE_LIMIT, + SIGNUP_RATE_LIMIT, +} from "@formbricks/lib/constants"; export const signUpLimiter = rateLimit({ interval: SIGNUP_RATE_LIMIT.interval, @@ -14,3 +19,8 @@ export const clientSideApiEndpointsLimiter = rateLimit({ interval: CLIENT_SIDE_API_RATE_LIMIT.interval, allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval, }); + +export const shareUrlLimiter = rateLimit({ + interval: SHARE_RATE_LIMIT.interval, + allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval, +}); diff --git a/apps/web/app/middleware/endpointValidator.ts b/apps/web/app/middleware/endpointValidator.ts index 8570ec4290..1515a9f8c9 100644 --- a/apps/web/app/middleware/endpointValidator.ts +++ b/apps/web/app/middleware/endpointValidator.ts @@ -8,3 +8,8 @@ export const clientSideApiRoute = (url: string): boolean => { const regex = /^\/api\/v\d+\/client\//; return regex.test(url); }; + +export const shareUrlRoute = (url: string): boolean => { + const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/; + return regex.test(url); +}; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 96fd27bab5..e3df726d6a 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,5 +1,15 @@ -import { clientSideApiEndpointsLimiter, loginLimiter, signUpLimiter } from "@/app/middleware/bucket"; -import { clientSideApiRoute, loginRoute, signupRoute } from "@/app/middleware/endpointValidator"; +import { + clientSideApiEndpointsLimiter, + loginLimiter, + shareUrlLimiter, + signUpLimiter, +} from "@/app/middleware/bucket"; +import { + clientSideApiRoute, + loginRoute, + shareUrlRoute, + signupRoute, +} from "@/app/middleware/endpointValidator"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; @@ -23,6 +33,8 @@ export async function middleware(request: NextRequest) { await signUpLimiter.check(ip); } else if (clientSideApiRoute(request.nextUrl.pathname)) { await clientSideApiEndpointsLimiter.check(ip); + } else if (shareUrlRoute(request.nextUrl.pathname)) { + await shareUrlLimiter.check(ip); } return res; } catch (_e) { @@ -41,5 +53,6 @@ export const config = { "/api/(.*)/client/:path*", "/api/v1/js/actions", "/api/v1/client/storage", + "/share/(.*)/:path", ], }; diff --git a/packages/database/migrations/20240102132851_add_result_share_key_to_survey/migration.sql b/packages/database/migrations/20240102132851_add_result_share_key_to_survey/migration.sql new file mode 100644 index 0000000000..24308101ee --- /dev/null +++ b/packages/database/migrations/20240102132851_add_result_share_key_to_survey/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[resultShareKey]` on the table `Survey` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Survey" ADD COLUMN "resultShareKey" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Survey_resultShareKey_key" ON "Survey"("resultShareKey"); diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 4a161743d9..d0b1c95153 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -299,6 +299,7 @@ model Survey { /// [SurveyVerifyEmail] verifyEmail Json? pin String? + resultShareKey String? @unique @@index([environmentId]) } diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index b2a31594d5..2e974d0a29 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -123,6 +123,10 @@ export const CLIENT_SIDE_API_RATE_LIMIT = { interval: 10 * 15 * 1000, // 15 minutes allowedPerInterval: 60, }; +export const SHARE_RATE_LIMIT = { + interval: 60 * 60 * 1000, // 60 minutes + allowedPerInterval: 30, +}; // Enterprise License constant export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY; diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 813470a1ae..2c79f4ddd6 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -49,6 +49,7 @@ export const selectSurvey = { surveyClosedMessage: true, singleUse: true, pin: true, + resultShareKey: true, triggers: { select: { actionClass: { @@ -686,3 +687,25 @@ export const getSyncSurveys = async (environmentId: string, person: TPerson): Pr )(); return surveys.map((survey) => formatDateFields(survey, ZSurvey)); }; + +export const getSurveyByResultShareKey = async (resultShareKey: string): Promise => { + try { + const survey = await prisma.survey.findFirst({ + where: { + resultShareKey, + }, + }); + + if (!survey) { + return null; + } + + return survey.id; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts index b2cf71f08d..3b812386ee 100644 --- a/packages/types/surveys.ts +++ b/packages/types/surveys.ts @@ -430,6 +430,7 @@ export const ZSurvey = z.object({ singleUse: ZSurveySingleUse.nullable(), verifyEmail: ZSurveyVerifyEmail.nullable(), pin: z.string().nullable().optional(), + resultShareKey: z.string().nullable(), }); export const ZSurveyInput = z.object({ diff --git a/packages/ui/SingleResponseCard/index.tsx b/packages/ui/SingleResponseCard/index.tsx index 56ba7b13e9..75c6ce06ac 100644 --- a/packages/ui/SingleResponseCard/index.tsx +++ b/packages/ui/SingleResponseCard/index.tsx @@ -8,6 +8,7 @@ import { useRouter } from "next/navigation"; import { ReactNode, useState } from "react"; import toast from "react-hot-toast"; +import { cn } from "@formbricks/lib/cn"; import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getPersonIdentifier } from "@formbricks/lib/person/util"; @@ -36,7 +37,7 @@ import ResponseTagsWrapper from "./components/ResponseTagsWrapper"; export interface SingleResponseCardProps { survey: TSurvey; response: TResponse; - user: TUser; + user?: TUser; pageType: string; environmentTags: TTag[]; environment: TEnvironment; @@ -221,23 +222,38 @@ export default function SingleResponseCard({ className={clsx( "relative z-10 my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm transition-all", pageType === "response" && - (isOpen ? "w-3/4" : response.notes.length ? "w-[96.5%]" : "w-full group-hover:w-[96.5%]") + (isOpen + ? "w-3/4" + : response.notes.length + ? "w-[96.5%]" + : cn("w-full", user ? "group-hover:w-[96.5%]" : "")) )}>
{pageType === "response" && (
{response.person?.id ? ( - - - - -

- {displayIdentifier} -

- + user ? ( + + + + +

+ {displayIdentifier} +

+ + ) : ( +
+ + + +

+ {displayIdentifier} +

+
+ ) ) : (
@@ -266,7 +282,7 @@ export default function SingleResponseCard({ - {!isViewer && ( + {user && !isViewer && ( { @@ -378,16 +394,18 @@ export default function SingleResponseCard({ )}
- - {!isViewer && ( - ({ tagId: tag.id, tagName: tag.name }))} - environmentTags={environmentTags} - /> - )} - + {user && ( + + {!isViewer && ( + ({ tagId: tag.id, tagName: tag.name }))} + environmentTags={environmentTags} + /> + )} + + )}
- {pageType === "response" && ( + {user && pageType === "response" && (