mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-24 06:28:49 -05:00
Merge branch 'main' of https://github.com/gupta-piyush19/formbricks into feat/close-date-edge-case
This commit is contained in:
@@ -17,8 +17,7 @@ export default function ConfirmationPage() {
|
||||
<div className="my-6 sm:flex-auto">
|
||||
<h1 className="text-center text-xl font-semibold text-slate-900">Upgrade successful</h1>
|
||||
<p className="mt-2 text-center text-sm text-slate-700">
|
||||
Thanks a lot for upgrading your formbricks subscription. You can now access all features and
|
||||
improve your user research.
|
||||
Thanks a lot for upgrading your Formbricks subscription. You have now unlimited access.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="darkCTA" className="w-full justify-center" href="/">
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
icon: CreditCardIcon,
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: IS_FORMBRICKS_CLOUD,
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Team } from "@prisma/client";
|
||||
import { ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { QuestionType } from "@formbricks/types/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
|
||||
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
|
||||
const newTeam = await prisma.team.create({
|
||||
@@ -1372,3 +1375,176 @@ export async function addDemoData(teamId: string): Promise<void> {
|
||||
InterviewPromptResults.displays
|
||||
);
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: existingSurvey.triggers.map((trigger) => ({
|
||||
eventClassId: trigger.id,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter) => ({
|
||||
attributeClassId: attributeFilter.attributeClassId,
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage
|
||||
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
|
||||
: prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
eventClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.eventClass.findFirst({
|
||||
where: {
|
||||
name: trigger.eventClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.eventClass.create({
|
||||
data: {
|
||||
name: trigger.eventClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.eventClass.description,
|
||||
type: trigger.eventClass.type,
|
||||
noCodeConfig: trigger.eventClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((eventClassId) => ({
|
||||
eventClassId: eventClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
@@ -7,10 +7,11 @@ import type { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
// upated on 20th of July 2023
|
||||
const stripeURl =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "https://buy.stripe.com/28o00R4GDf9qdfa5kp"
|
||||
: "https://buy.stripe.com/test_9AQfZw5CL9hmcXSdQQ";
|
||||
? "https://buy.stripe.com/5kA9ABal07ZjgEw3cc"
|
||||
: "https://buy.stripe.com/test_8wMaHA3UWcACfuM3cc";
|
||||
|
||||
interface PricingTableProps {
|
||||
environmentId: string;
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { getServerSession } from "next-auth";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import PricingTable from "./PricingTable";
|
||||
|
||||
const proPlan = false;
|
||||
|
||||
export default async function ProfileSettingsPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Billing & Plan" />
|
||||
{proPlan ? (
|
||||
<SettingsCard
|
||||
title="Manage subscription"
|
||||
description="View, update and cancel your subscription in the billing portal.">
|
||||
<Button variant="darkCTA">Billing Portal</Button>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<PricingTable environmentId={params.environmentId} session={session} />
|
||||
)}
|
||||
<PricingTable environmentId={params.environmentId} session={session} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
survey: TSurveyWithAnalytics;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
environmentId,
|
||||
survey,
|
||||
environment,
|
||||
otherEnvironment,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
router.refresh();
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
} catch (error) {
|
||||
toast.error("An error occured while deleting survey");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
router.refresh();
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
toast.success("Survey copied to development env.");
|
||||
}
|
||||
router.replace(`/environments/${otherEnvironment.id}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy to ${otherEnvironment.type}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{environment.type === "development" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Prod
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : environment.type === "production" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Dev
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteSurvey(survey)}
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Template } from "@/../../packages/types/templates";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { createSurvey, deleteSurvey, duplicateSurvey, useSurveys } from "@/lib/surveys/surveys";
|
||||
import { Badge, ErrorComponent } from "@formbricks/ui";
|
||||
import {
|
||||
ComputerDesktopIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
ArrowUpOnSquareStackIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Badge } from "@formbricks/ui";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import TemplateList from "./templates/TemplateList";
|
||||
import { useEffect } from "react";
|
||||
import { changeEnvironment } from "@/lib/environments/changeEnvironments";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
interface SurveyListProps {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export default function SurveysList({ environmentId, product }: SurveyListProps) {
|
||||
const router = useRouter();
|
||||
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
|
||||
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
|
||||
const [activeSurvey, setActiveSurvey] = useState("" as any);
|
||||
const [activeSurveyIdx, setActiveSurveyIdx] = useState("" as any);
|
||||
const [otherEnvironment, setOtherEnvironment] = useState("" as any);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
setOtherEnvironment(environment.product.environments.find((e) => e.type !== environment.type));
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
const newSurvey = async () => {
|
||||
router.push(`/environments/${environmentId}/surveys/templates`);
|
||||
};
|
||||
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
try {
|
||||
const survey = await createSurvey(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error("An error occured creating a new survey");
|
||||
setIsCreateSurveyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSurveyAction = async (survey, surveyIdx) => {
|
||||
try {
|
||||
await deleteSurvey(environmentId, survey.id);
|
||||
// remove locally
|
||||
const updatedsurveys = JSON.parse(JSON.stringify(surveys));
|
||||
updatedsurveys.splice(surveyIdx, 1);
|
||||
mutateSurveys(updatedsurveys);
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
try {
|
||||
await duplicateSurvey(environmentId, surveyId);
|
||||
mutateSurveys();
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
try {
|
||||
await duplicateSurvey(environmentId, surveyId, otherEnvironment.id);
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
toast.success("Survey copied to development env.");
|
||||
}
|
||||
changeEnvironment(otherEnvironment.type, environment, router);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy to ${otherEnvironment.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingSurveys || isLoadingEnvironment) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorSurveys || isErrorEnvironment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type);
|
||||
|
||||
if (surveys.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
|
||||
{isCreateSurveyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-7 pb-4">
|
||||
<h1 className="text-3xl font-extrabold text-slate-700">
|
||||
You're all set! Time to create your first survey.
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <SurveyStarter environmentId={environmentId} environment={environment} product={product} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
|
||||
<button onClick={() => newSurvey()}>
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
|
||||
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
|
||||
@@ -159,10 +33,10 @@ export default function SurveysList({ environmentId, product }: SurveyListProps)
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</button>
|
||||
</Link>
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((survey, surveyIdx) => (
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.map((survey) => (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
@@ -198,119 +72,28 @@ export default function SurveysList({ environmentId, product }: SurveyListProps)
|
||||
tooltip
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
<p className="ml-2 text-xs text-slate-400 ">{survey._count?.responses} responses</p>
|
||||
<p className="ml-2 text-xs text-slate-400 ">
|
||||
{survey.analytics.numResponses} responses
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{environment.type === "development" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Prod
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : environment.type === "production" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Dev
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
setActiveSurvey(survey);
|
||||
setActiveSurveyIdx(surveyIdx);
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`survey-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => deleteSurveyAction(activeSurvey, activeSurveyIdx)}
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import { Template } from "@/../../packages/types/templates";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export default function SurveyStarter({
|
||||
environmentId,
|
||||
environment,
|
||||
product,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
}) {
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error("An error occured creating a new survey");
|
||||
setIsCreateSurveyLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
|
||||
{isCreateSurveyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-7 pb-4">
|
||||
<h1 className="text-3xl font-extrabold text-slate-700">
|
||||
You're all set! Time to create your first survey.
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { getSurveyResponses } from "@formbricks/lib/services/response";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
|
||||
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
|
||||
const [survey, team, allResponses] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getSurveyWithAnalytics(surveyId),
|
||||
getTeamByEnvironmentId(environmentId),
|
||||
getSurveyResponses(surveyId),
|
||||
]);
|
||||
|
||||
-7
@@ -30,13 +30,6 @@ export interface OpenTextSummaryProps {
|
||||
scale?: "number" | "star" | "smiley";
|
||||
range?: number;
|
||||
}[];
|
||||
meta?: {
|
||||
userAgent?: {
|
||||
browser?: string;
|
||||
os?: string;
|
||||
device?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { createSurvey } from "@formbricks/lib/services/survey";
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: any) {
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -2,19 +2,13 @@ import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
|
||||
import SurveysList from "./SurveyList";
|
||||
import { Metadata } from "next";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
};
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
const environmentId = params.environmentId;
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
<SurveysList environmentId={params.environmentId} product={product} />
|
||||
<SurveysList environmentId={params.environmentId} />
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
+18
-12
@@ -1,21 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { createSurvey } from "@/lib/surveys/surveys";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { replacePresetPlaceholders } from "@/lib/templates";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import type { Template } from "@formbricks/types/templates";
|
||||
import { Button, ErrorComponent } from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { SparklesIcon } from "@heroicons/react/24/solid";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
import { SplitIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import {
|
||||
Button,
|
||||
ErrorComponent,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { SparklesIcon } from "@heroicons/react/24/solid";
|
||||
import { SplitIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
|
||||
type TemplateList = {
|
||||
environmentId: string;
|
||||
@@ -59,7 +65,7 @@ export default function TemplateList({ environmentId, onTemplateClick, product,
|
||||
...activeTemplate.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
const survey = await createSurvey(environmentId, augmentedTemplate);
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { createSurvey } from "@formbricks/lib/services/survey";
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: any) {
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return "/auth/error?error=email-conflict";
|
||||
return "/auth/login?error=Looks%20like%20you%20updated%20your%20email%20somewhere%20else.%0AA%20user%20with%20this%20new%20email%20exists%20already.";
|
||||
}
|
||||
|
||||
// There is no existing account for this identity provider / account id
|
||||
@@ -251,7 +251,7 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
return "/auth/error?error=use-email-login";
|
||||
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
|
||||
@@ -63,7 +63,7 @@ const notificationInsight = (insights: Insights) =>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completion %</p>
|
||||
<h1>${insights.completionRate.toFixed(2)}%</h1>
|
||||
<h1>${insights.totalDisplays === 0 ? "N/A" : `${Math.round(insights.completionRate)}%`}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -91,30 +91,34 @@ const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
|
||||
if (!surveys.length) return ` `;
|
||||
|
||||
return surveys
|
||||
.filter((survey) => survey.responses.length > 0)
|
||||
.map((survey) => {
|
||||
const displayStatus = convertSurveyStatus(survey.status);
|
||||
const isLive = displayStatus === "Live";
|
||||
const noResponseLastWeek = isLive && survey.responses.length === 0;
|
||||
|
||||
return `
|
||||
<div style="display: block; margin-top:3em;">
|
||||
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
|
||||
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
|
||||
</a>
|
||||
<span style="display: inline; margin-left: 10px; background-color: ${
|
||||
isLive ? "#34D399" : "#a7f3d0"
|
||||
isLive ? "#34D399" : "#cbd5e1"
|
||||
}; color: ${isLive ? "#F3F4F6" : "#15803d"}; border-radius:99px; padding: 2px 8px; font-size:0.9em">
|
||||
${displayStatus}
|
||||
</span>
|
||||
${createSurveyFields(survey.responses)}
|
||||
${
|
||||
survey.responsesCount >= 1
|
||||
noResponseLastWeek
|
||||
? "<p>No new response received this week 🕵️</p>"
|
||||
: createSurveyFields(survey.responses)
|
||||
}
|
||||
${
|
||||
survey.responsesCount >= 0
|
||||
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA">
|
||||
${getButtonLabel(survey.responsesCount)}
|
||||
${noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responsesCount)}
|
||||
</a>`
|
||||
: ""
|
||||
}
|
||||
@@ -154,8 +158,7 @@ const notificationFooter = () => {
|
||||
return `
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
<p style="margin-top:0.8em; text-align:center; font-size:0.8em; line-height:1em;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
`;
|
||||
};
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri
|
||||
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
|
||||
insights.totalDisplays += survey.displays.length;
|
||||
insights.totalResponses += survey.responses.length;
|
||||
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalDisplays) * 100);
|
||||
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
|
||||
}
|
||||
// build the notification response needed for the emails
|
||||
const lastWeekDate = new Date();
|
||||
@@ -160,6 +160,11 @@ const getProducts = async (): Promise<ProductData[]> => {
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
|
||||
@@ -1,56 +1,43 @@
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AttributeClass } from "@prisma/client";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { sendResponseFinishedEmail } from "@/lib/email";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { NotificationSettings } from "@formbricks/types/users";
|
||||
import { ZPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { internalSecret, environmentId, surveyId, event, data } = await request.json();
|
||||
if (!internalSecret) {
|
||||
console.error("Pipeline: Missing internalSecret");
|
||||
return new Response("Missing internalSecret", {
|
||||
status: 400,
|
||||
});
|
||||
// check authentication with x-api-key header and CRON_SECRET env variable
|
||||
if (headers().get("x-api-key") !== INTERNAL_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
if (!environmentId) {
|
||||
console.error("Pipeline: Missing environmentId");
|
||||
return new Response("Missing environmentId", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!surveyId) {
|
||||
console.error("Pipeline: Missing surveyId");
|
||||
return new Response("Missing surveyId", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!event) {
|
||||
console.error("Pipeline: Missing event");
|
||||
return new Response("Missing event", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!data) {
|
||||
console.error("Pipeline: Missing data");
|
||||
return new Response("Missing data", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (internalSecret !== INTERNAL_SECRET) {
|
||||
console.error("Pipeline: internalSecret doesn't match");
|
||||
return new Response("Invalid internalSecret", {
|
||||
status: 401,
|
||||
});
|
||||
const jsonInput = await request.json();
|
||||
|
||||
convertDatesInObject(jsonInput);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
console.error(inputValidation.error);
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId, surveyId, event, response } = inputValidation.data;
|
||||
|
||||
// get all webhooks of this environment where event in triggers
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
triggers: {
|
||||
hasSome: event,
|
||||
has: event,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
@@ -75,7 +62,7 @@ export async function POST(request: Request) {
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data,
|
||||
data: response,
|
||||
}),
|
||||
});
|
||||
})
|
||||
@@ -136,32 +123,10 @@ export async function POST(request: Request) {
|
||||
name: surveyData.name,
|
||||
questions: JSON.parse(JSON.stringify(surveyData.questions)) as Question[],
|
||||
};
|
||||
// get person for response
|
||||
let person: {
|
||||
id: string;
|
||||
attributes: { id: string; value: string; attributeClass: AttributeClass }[];
|
||||
} | null;
|
||||
if (data.personId) {
|
||||
person = await prisma.person.findUnique({
|
||||
where: {
|
||||
id: data.personId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// send email to all users
|
||||
await Promise.all(
|
||||
usersWithNotifications.map(async (user) => {
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, data, person);
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,11 +68,7 @@ export async function PUT(
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
// only send the updated fields
|
||||
data: {
|
||||
...response,
|
||||
data: inputValidation.data.data,
|
||||
},
|
||||
response,
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
@@ -82,7 +78,7 @@ export async function PUT(
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
data: response,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
data: response,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
@@ -76,7 +76,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
data: response,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { select } from "@formbricks/lib/services/survey";
|
||||
import { selectSurvey } from "@formbricks/lib/services/survey";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
@@ -48,7 +48,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis
|
||||
],
|
||||
},
|
||||
select: {
|
||||
...select,
|
||||
...selectSurvey,
|
||||
attributeFilters: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function GET() {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
@@ -2,8 +2,7 @@ import { env } from "@/env.mjs";
|
||||
import { getQuestionResponseMapping } from "@/lib/responses/questionResponseMapping";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { Response } from "@formbricks/types/responses";
|
||||
import { AttributeClass } from "@prisma/client";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { withEmailTemplate } from "./email-template";
|
||||
import { createInviteToken, createToken } from "./jwt";
|
||||
|
||||
@@ -120,16 +119,15 @@ export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
environmentId: string,
|
||||
survey: { id: string; name: string; questions: Question[] },
|
||||
response: Response,
|
||||
person: { id: string; attributes: { id: string; value: string; attributeClass: AttributeClass }[] } | null
|
||||
response: TResponse
|
||||
) => {
|
||||
const personEmail = person?.attributes?.find((a) => a.attributeClass?.name === "email")?.value;
|
||||
const personEmail = response.person?.attributes["email"];
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail || env.MAIL_FROM,
|
||||
replyTo: personEmail?.toString() || env.MAIL_FROM,
|
||||
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
|
||||
survey.name
|
||||
}</strong><br/>
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
export async function sendToPipeline({
|
||||
event,
|
||||
surveyId,
|
||||
environmentId,
|
||||
data,
|
||||
}: {
|
||||
event: TPipelineTrigger;
|
||||
surveyId: string;
|
||||
environmentId: string;
|
||||
data: any;
|
||||
}) {
|
||||
export async function sendToPipeline({ event, surveyId, environmentId, response }: TPipelineInput) {
|
||||
return fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId: environmentId,
|
||||
surveyId: surveyId,
|
||||
event,
|
||||
data,
|
||||
response,
|
||||
}),
|
||||
}).catch((error) => {
|
||||
console.error(`Error sending event to pipeline: ${error}`);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { Response } from "@formbricks/types/responses";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
|
||||
export const getQuestionResponseMapping = (
|
||||
survey: { questions: Question[] },
|
||||
response: Response
|
||||
response: TResponse
|
||||
): { question: string; answer: string }[] => {
|
||||
const questionResponseMapping: { question: string; answer: string }[] = [];
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getQuestionResponseMapping = (
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: question.headline,
|
||||
answer,
|
||||
answer: answer.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+91
-19
@@ -1,5 +1,10 @@
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -42,16 +47,90 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
...response.data,
|
||||
};
|
||||
|
||||
// update response
|
||||
const responseData = await prisma.response.update({
|
||||
const responsePrisma = await prisma.response.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
data: {
|
||||
...{ ...response, data: newResponseData },
|
||||
...response,
|
||||
data: newResponseData,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const transformPrismaPerson = (person): TPerson => {
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
attributes: attributes,
|
||||
createdAt: person.createdAt,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const responseData: TResponse = {
|
||||
...responsePrisma,
|
||||
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
// send response update to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
@@ -64,29 +143,22 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
event: "responseUpdated",
|
||||
data: { id: responseId, ...response },
|
||||
}),
|
||||
response: responseData,
|
||||
} as TPipelineInput),
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
event: "responseFinished",
|
||||
data: responseData,
|
||||
}),
|
||||
sendToPipeline({
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
event: "responseFinished",
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(responseData);
|
||||
return res.json({ message: "Response updated" });
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const environmentId = req.query.environmentId?.toString();
|
||||
@@ -13,7 +16,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
// CORS
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
// POST
|
||||
@@ -75,19 +78,17 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
// find team owner
|
||||
const teamOwnerId = environment.product.team.memberships.find((m) => m.role === "owner")?.userId;
|
||||
|
||||
const createBody = {
|
||||
data: {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
const responseInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
...response,
|
||||
},
|
||||
...response,
|
||||
};
|
||||
|
||||
if (personId) {
|
||||
createBody.data.person = {
|
||||
responseInput.data.person = {
|
||||
connect: {
|
||||
id: personId,
|
||||
},
|
||||
@@ -95,39 +96,103 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
// create new response
|
||||
const responseData = await prisma.response.create(createBody);
|
||||
const responsePrisma = await prisma.response.create({
|
||||
data: {
|
||||
...responseInput,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const transformPrismaPerson = (person): TPerson => {
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
attributes: attributes,
|
||||
createdAt: person.createdAt,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const responseData: TResponse = {
|
||||
...responsePrisma,
|
||||
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseCreated",
|
||||
data: responseData,
|
||||
}),
|
||||
sendToPipeline({
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseCreated",
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
internalSecret: INTERNAL_SECRET,
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseFinished",
|
||||
data: responseData,
|
||||
}),
|
||||
sendToPipeline({
|
||||
environmentId,
|
||||
surveyId,
|
||||
event: "responseFinished",
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user