mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
Merge branch 'formbricks:main' into fix/keyboard-form-usablity
This commit is contained in:
@@ -101,3 +101,7 @@ GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
|
||||
# Encryption key
|
||||
# You can use: `openssl rand -base64 16` to generate one
|
||||
FORMBRICKS_ENCRYPTION_KEY=
|
||||
@@ -104,4 +104,8 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
*/
|
||||
*/
|
||||
|
||||
# Encryption key
|
||||
# You can use: `openssl rand -base64 16` to generate one
|
||||
FORMBRICKS_ENCRYPTION_KEY=
|
||||
@@ -57,7 +57,7 @@ These variables must also be provided at runtime.
|
||||
| Variable | Description | Required | Default |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
|
||||
| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` |
|
||||
| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` |
|
||||
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
|
||||
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
|
||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
|
||||
|
||||
@@ -299,8 +299,8 @@ const Leaderboard = [
|
||||
points: "200",
|
||||
},
|
||||
{
|
||||
name: "Arjun",
|
||||
points: "100",
|
||||
name: "Naitik Kapadia (Arjun)",
|
||||
points: "200",
|
||||
},
|
||||
{
|
||||
name: "Yashhhh",
|
||||
@@ -332,7 +332,7 @@ const Leaderboard = [
|
||||
},
|
||||
{
|
||||
name: "Eldemarkki",
|
||||
points: "100",
|
||||
points: "500",
|
||||
},
|
||||
{
|
||||
name: "Suyash",
|
||||
@@ -360,17 +360,57 @@ const Leaderboard = [
|
||||
},
|
||||
{
|
||||
name: "Aditya Deshlahre",
|
||||
points: "450",
|
||||
points: "550",
|
||||
link: "https://github.com/adityadeshlahre",
|
||||
},
|
||||
{
|
||||
name: "Rutam",
|
||||
points: "250",
|
||||
points: "350",
|
||||
},
|
||||
{
|
||||
name: "Sagnik Sahoo",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Prasoon Mahawar",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Dushmanta",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Arjavv",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Ashish Khare",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rohit Mondal",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "noobcoder",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rayyan Alam (Rayy)",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Ayush",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Zechariah",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rajarshi Misra",
|
||||
points: "100",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FormTribeHackathon() {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getActionCountInLast7Days,
|
||||
getActionCountInLastHour,
|
||||
} from "@formbricks/lib/services/actions";
|
||||
import { getSurveysByActionClassId } from "@formbricks/lib/services/survey";
|
||||
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export async function deleteActionClassAction(environmentId, actionClassId: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getSurveysByAttributeClassId } from "@formbricks/lib/services/survey";
|
||||
import { getSurveysByAttributeClassId } from "@formbricks/lib/survey/service";
|
||||
|
||||
export const GetActiveInactiveSurveysAction = async (
|
||||
attributeClassId: string
|
||||
|
||||
@@ -1,133 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createMembership } from "@formbricks/lib/services/membership";
|
||||
import { createProduct } from "@formbricks/lib/services/product";
|
||||
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { deleteSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
import { createProduct } from "@formbricks/lib/services/product";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
|
||||
const newTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
memberships: {
|
||||
create: {
|
||||
user: { connect: { id: ownerUserId } },
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
products: {
|
||||
create: [
|
||||
{
|
||||
name: "My Product",
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: "production",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "development",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
products: {
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
export async function createTeamAction(teamName: string): Promise<Team> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const newTeam = await createTeam({
|
||||
name: teamName,
|
||||
});
|
||||
|
||||
const teamId = newTeam?.id;
|
||||
await createMembership(newTeam.id, session.user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
});
|
||||
}
|
||||
await createProduct(newTeam.id, {
|
||||
name: "My Product",
|
||||
});
|
||||
|
||||
return newTeam;
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
if (!existingSurvey) {
|
||||
@@ -164,7 +76,9 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage
|
||||
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
|
||||
: prismaClient.JsonNull,
|
||||
|
||||
singleUse: existingSurvey.singleUse
|
||||
? JSON.parse(JSON.stringify(existingSurvey.singleUse))
|
||||
: prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail
|
||||
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
|
||||
: prismaClient.JsonNull,
|
||||
@@ -178,6 +92,24 @@ export async function copyToOtherEnvironmentAction(
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
@@ -295,6 +227,7 @@ export async function copyToOtherEnvironmentAction(
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
@@ -302,12 +235,32 @@ export async function copyToOtherEnvironmentAction(
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export const createProductAction = async (environmentId: string, productName: string) => {
|
||||
const productCreated = await createProduct(environmentId, productName);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const newEnvironment = productCreated.environments[0];
|
||||
return newEnvironment;
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
if (!team) throw new ResourceNotFoundError("Team from environment", environmentId);
|
||||
|
||||
const product = await createProduct(team.id, {
|
||||
name: productName,
|
||||
});
|
||||
|
||||
// get production environment
|
||||
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
|
||||
if (!productionEnvironment) throw new ResourceNotFoundError("Production environment", environmentId);
|
||||
|
||||
return productionEnvironment;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integra
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
|
||||
import { getIntegrations } from "@formbricks/lib/services/integrations";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
|
||||
@@ -4,7 +4,7 @@ import WebhookRowData from "@/app/(app)/environments/[environmentId]/integration
|
||||
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
|
||||
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getWebhooks } from "@formbricks/lib/services/webhook";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
@@ -3,6 +3,7 @@ import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import Link from "next/link";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
export default function ResponseFeed({
|
||||
@@ -63,6 +64,7 @@ export default function ResponseFeed({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{response.survey.questions.map((question) => (
|
||||
<div key={question.id}>
|
||||
@@ -75,6 +77,18 @@ export default function ResponseFeed({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-sm text-slate-500">{response.singleUseId}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm text-slate-500">Single Use Id</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { updateTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
||||
import { updateTeamNameAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -43,7 +43,7 @@ export default function EditTeamName({ team }: TEditTeamNameProps) {
|
||||
const handleUpdateTeamName: SubmitHandler<TEditTeamNameForm> = async (data) => {
|
||||
try {
|
||||
setIsUpdatingTeam(true);
|
||||
await updateTeamAction(team.id, data);
|
||||
await updateTeamNameAction(team.id, data.name);
|
||||
|
||||
setIsUpdatingTeam(false);
|
||||
toast.success("Team name updated successfully.");
|
||||
|
||||
@@ -20,13 +20,22 @@ import {
|
||||
import { deleteTeam, updateTeam } from "@formbricks/lib/services/team";
|
||||
import { TInviteUpdateInput } from "@formbricks/types/v1/invites";
|
||||
import { TMembershipRole, TMembershipUpdateInput } from "@formbricks/types/v1/memberships";
|
||||
import { TTeamUpdateInput } from "@formbricks/types/v1/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { hasTeamAccess, hasTeamAuthority, hasTeamOwnership, isOwner } from "@formbricks/lib/auth";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
|
||||
export const updateTeamAction = async (teamId: string, data: TTeamUpdateInput) => {
|
||||
return await updateTeam(teamId, data);
|
||||
export const updateTeamNameAction = async (teamId: string, teamName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
|
||||
if (!isUserAuthorized) {
|
||||
throw new AuthenticationError("Not authorized");
|
||||
}
|
||||
|
||||
return await updateTeam(teamId, { name: teamName });
|
||||
};
|
||||
|
||||
export const updateMembershipAction = async (
|
||||
|
||||
@@ -36,6 +36,7 @@ interface SurveyDropDownMenuProps {
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
surveyBaseUrl: string;
|
||||
singleUseId?: string;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
@@ -44,6 +45,7 @@ export default function SurveyDropDownMenu({
|
||||
environment,
|
||||
otherEnvironment,
|
||||
surveyBaseUrl,
|
||||
singleUseId,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -155,7 +157,11 @@ export default function SurveyDropDownMenu({
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
href={
|
||||
singleUseId
|
||||
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
|
||||
: `/s/${survey.id}?preview=true`
|
||||
}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
@@ -165,8 +171,11 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
navigator.clipboard.writeText(
|
||||
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
router.refresh();
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
|
||||
@@ -5,11 +5,12 @@ import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { Badge } from "@formbricks/ui";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
@@ -45,57 +46,63 @@ export default async function SurveysList({ environmentId }: { environmentId: st
|
||||
</Link>
|
||||
{surveys
|
||||
.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">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
.map((survey) => {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
|
||||
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
|
||||
return (
|
||||
<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">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
singleUseId={singleUseId}
|
||||
/>
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} />
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templ
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TSurveyInput } from "@formbricks/types/v1/surveys";
|
||||
import { TTemplate } from "@formbricks/types/v1/templates";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -28,7 +29,7 @@ export default function SurveyStarter({
|
||||
...template.preset,
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
};
|
||||
} as Partial<TSurveyInput>;
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/survey/service";
|
||||
import { getSurveyResponses } from "@formbricks/lib/response/service";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
|
||||
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
|
||||
|
||||
@@ -147,6 +147,11 @@ export default function SingleResponse({
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4 text-sm">
|
||||
{data.singleUseId && (
|
||||
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
|
||||
{data.singleUseId}
|
||||
</span>
|
||||
)}
|
||||
{data.finished && (
|
||||
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
|
||||
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
|
||||
|
||||
@@ -6,19 +6,23 @@ import { Button } from "@formbricks/ui";
|
||||
import { ShareIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
|
||||
interface LinkSurveyShareButtonProps {
|
||||
survey: TSurvey;
|
||||
className?: string;
|
||||
surveyBaseUrl: string;
|
||||
singleUseIds?: string[];
|
||||
}
|
||||
|
||||
export default function LinkSurveyShareButton({
|
||||
survey,
|
||||
className,
|
||||
surveyBaseUrl,
|
||||
singleUseIds,
|
||||
}: LinkSurveyShareButtonProps) {
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,7 +35,14 @@ export default function LinkSurveyShareButton({
|
||||
onClick={() => setShowLinkModal(true)}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
{showLinkModal && (
|
||||
{showLinkModal && isSingleUse && singleUseIds ? (
|
||||
<LinkSingleUseSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
) : (
|
||||
<LinkSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Dialog, DialogContent } from "@formbricks/ui";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { truncateMiddle } from "@/lib/utils";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface LinkSingleUseSurveyModalProps {
|
||||
survey: TSurvey;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
singleUseIds: string[];
|
||||
}
|
||||
|
||||
export default function LinkSingleUseSurveyModal({
|
||||
survey,
|
||||
open,
|
||||
setOpen,
|
||||
singleUseIds,
|
||||
}: LinkSingleUseSurveyModalProps) {
|
||||
const defaultSurveyUrl = `${window.location.protocol}//${window.location.host}/s/${survey.id}`;
|
||||
const [selectedSingleUseIds, setSelectedSingleIds] = useState<number[]>([]);
|
||||
|
||||
const linkTextRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLinkOnClick = (index: number) => {
|
||||
setSelectedSingleIds([...selectedSingleUseIds, index]);
|
||||
const surveyUrl = `${defaultSurveyUrl}?suId=${singleUseIds[index]}`;
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success("URL copied to clipboard!");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
|
||||
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your survey is ready!</h3>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Here are 5 single use links to let people answer your survey:
|
||||
</p>
|
||||
<div ref={linkTextRef}>
|
||||
{singleUseIds.map((singleUseId, index) => {
|
||||
const isSelected = selectedSingleUseIds.includes(index);
|
||||
return (
|
||||
<div
|
||||
key={singleUseId}
|
||||
className={cn(
|
||||
"row relative mt-3 flex max-w-full cursor-pointer items-center justify-between overflow-auto rounded-lg border border-slate-300 bg-slate-50 px-8 py-4 text-left text-slate-800 transition-all duration-200 ease-in-out hover:border-slate-500",
|
||||
isSelected && "border-slate-200 text-slate-400 hover:border-slate-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSelected) {
|
||||
handleLinkOnClick(index);
|
||||
}
|
||||
}}>
|
||||
<span>{truncateMiddle(`${defaultSurveyUrl}?suId=${singleUseId}`, 48)}</span>
|
||||
{isSelected ? (
|
||||
<CheckCircleIcon className="ml-4 h-4 w-4" />
|
||||
) : (
|
||||
<DocumentDuplicateIcon className="ml-4 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Generate new single-use survey link"
|
||||
aria-label="Generate new single-use survey link"
|
||||
className="flex justify-center"
|
||||
onClick={() => {
|
||||
router.refresh();
|
||||
setSelectedSingleIds([]);
|
||||
toast.success("New survey links generated!");
|
||||
}}
|
||||
EndIcon={ArrowPathIcon}>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSelectedSingleIds(Array.from(singleUseIds.keys()));
|
||||
const allSurveyUrls = singleUseIds
|
||||
.map((singleUseId) => `${defaultSurveyUrl}?suId=${singleUseId}`)
|
||||
.join("\n");
|
||||
navigator.clipboard.writeText(allSurveyUrls);
|
||||
toast.success("All URLs copied to clipboard!");
|
||||
}}
|
||||
title="Copy all survey links to clipboard"
|
||||
aria-label="Copy all survey links to clipboard"
|
||||
className="flex justify-center"
|
||||
EndIcon={DocumentDuplicateIcon}>
|
||||
Copy 5 URLs
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
title="Preview survey"
|
||||
aria-label="Preview survey"
|
||||
className="flex justify-center"
|
||||
href={`${defaultSurveyUrl}?suId=${singleUseIds[0]}preview=true`}
|
||||
target="_blank"
|
||||
EndIcon={EyeIcon}>
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,23 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkSurveyModal from "./LinkSurveyModal";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyBaseUrl: string;
|
||||
singleUseIds?: string[];
|
||||
}
|
||||
|
||||
export default function SuccessMessage({ environment, survey, surveyBaseUrl }: SummaryMetadataProps) {
|
||||
export default function SuccessMessage({
|
||||
environment,
|
||||
survey,
|
||||
surveyBaseUrl,
|
||||
singleUseIds,
|
||||
}: SummaryMetadataProps) {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const searchParams = useSearchParams();
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
@@ -45,7 +53,14 @@ export default function SuccessMessage({ environment, survey, surveyBaseUrl }: S
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLinkModal && (
|
||||
{showLinkModal && isSingleUse && singleUseIds ? (
|
||||
<LinkSingleUseSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
) : (
|
||||
<LinkSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SummaryPageProps {
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
surveyBaseUrl: string;
|
||||
singleUseIds?: string[];
|
||||
product: TProduct;
|
||||
environmentTags: TTag[];
|
||||
}
|
||||
@@ -32,6 +33,7 @@ const SummaryPage = ({
|
||||
surveyId,
|
||||
responses,
|
||||
surveyBaseUrl,
|
||||
singleUseIds,
|
||||
product,
|
||||
environmentTags,
|
||||
}: SummaryPageProps) => {
|
||||
@@ -56,6 +58,7 @@ const SummaryPage = ({
|
||||
survey={survey}
|
||||
surveyId={surveyId}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
product={product}
|
||||
/>
|
||||
<CustomFilter
|
||||
|
||||
@@ -9,16 +9,28 @@ import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
const generateSingleUseIds = (isEncrypted: boolean) => {
|
||||
return Array(5)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
return generateSurveySingleUseId(isEncrypted);
|
||||
});
|
||||
};
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const [{ responses, survey }, environment] = await Promise.all([
|
||||
getAnalysisData(params.surveyId, params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
const isSingleUseSurvey = survey.singleUse?.enabled ?? false;
|
||||
const singleUseIds = generateSingleUseIds(survey.singleUse?.isEncrypted ?? false);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
@@ -38,6 +50,7 @@ export default async function Page({ params }) {
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
singleUseIds={isSingleUseSurvey ? singleUseIds : undefined}
|
||||
product={product}
|
||||
environmentTags={tags}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surv
|
||||
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
|
||||
interface SummaryHeaderProps {
|
||||
surveyId: string;
|
||||
@@ -31,8 +31,16 @@ interface SummaryHeaderProps {
|
||||
survey: TSurvey;
|
||||
surveyBaseUrl: string;
|
||||
product: TProduct;
|
||||
singleUseIds?: string[];
|
||||
}
|
||||
const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }: SummaryHeaderProps) => {
|
||||
const SummaryHeader = ({
|
||||
surveyId,
|
||||
environment,
|
||||
survey,
|
||||
surveyBaseUrl,
|
||||
product,
|
||||
singleUseIds,
|
||||
}: SummaryHeaderProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const isCloseOnDateEnabled = survey.closeOnDate !== null;
|
||||
@@ -46,7 +54,9 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
|
||||
<span className="text-base font-extralight text-slate-600">{product.name}</span>
|
||||
</div>
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{survey.type === "link" && <LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} />}
|
||||
{survey.type === "link" && (
|
||||
<LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} singleUseIds={singleUseIds} />
|
||||
)}
|
||||
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
) : null}
|
||||
@@ -72,6 +82,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
|
||||
className="flex w-full justify-center p-1"
|
||||
survey={survey}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
@@ -97,7 +108,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
|
||||
value={survey.status}
|
||||
onValueChange={(value) => {
|
||||
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
|
||||
surveyMutateAction({ ...survey, status: castedValue })
|
||||
updateSurveyAction({ ...survey, status: castedValue })
|
||||
.then(() => {
|
||||
toast.success(
|
||||
value === "inProgress"
|
||||
@@ -149,7 +160,12 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<SuccessMessage environment={environment} survey={survey} surveyBaseUrl={surveyBaseUrl} />
|
||||
<SuccessMessage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui";
|
||||
import {
|
||||
AdvancedOptionToggle,
|
||||
DatePicker,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -10,9 +20,14 @@ import toast from "react-hot-toast";
|
||||
interface ResponseOptionsCardProps {
|
||||
localSurvey: TSurveyWithAnalytics;
|
||||
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
|
||||
isEncryptionKeySet: boolean;
|
||||
}
|
||||
|
||||
export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: ResponseOptionsCardProps) {
|
||||
export default function ResponseOptionsCard({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
isEncryptionKeySet,
|
||||
}: ResponseOptionsCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const autoComplete = localSurvey.autoComplete !== null;
|
||||
const [redirectToggle, setRedirectToggle] = useState(false);
|
||||
@@ -27,6 +42,12 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
subheading: "This free & open-source survey has been closed",
|
||||
});
|
||||
|
||||
const [singleUseMessage, setSingleUseMessage] = useState({
|
||||
heading: "The survey has already been answered.",
|
||||
subheading: "You can only use this link once.",
|
||||
});
|
||||
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(isEncryptionKeySet);
|
||||
const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({
|
||||
name: "",
|
||||
subheading: "",
|
||||
@@ -104,6 +125,53 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
|
||||
};
|
||||
|
||||
const handleSingleUseSurveyToggle = () => {
|
||||
if (!localSurvey.singleUse?.enabled) {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: singleUseEncryption },
|
||||
});
|
||||
} else {
|
||||
setLocalSurvey({ ...localSurvey, singleUse: { enabled: false, isEncrypted: false } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseSurveyMessageChange = ({
|
||||
heading,
|
||||
subheading,
|
||||
}: {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
}) => {
|
||||
const message = {
|
||||
heading: heading ?? singleUseMessage.heading,
|
||||
subheading: subheading ?? singleUseMessage.subheading,
|
||||
};
|
||||
|
||||
const localSurveySingleUseEnabled = localSurvey.singleUse?.enabled ?? false;
|
||||
setSingleUseMessage(message);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: localSurveySingleUseEnabled, ...message, isEncrypted: singleUseEncryption },
|
||||
});
|
||||
};
|
||||
|
||||
const hangleSingleUseEncryptionToggle = () => {
|
||||
if (!singleUseEncryption) {
|
||||
setSingleUseEncryption(true);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: true },
|
||||
});
|
||||
} else {
|
||||
setSingleUseEncryption(false);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmailSurveyDetailsChange = ({
|
||||
name,
|
||||
subheading,
|
||||
@@ -134,6 +202,14 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
setSurveyClosedMessageToggle(true);
|
||||
}
|
||||
|
||||
if (localSurvey.singleUse?.enabled) {
|
||||
setSingleUseMessage({
|
||||
heading: localSurvey.singleUse.heading ?? singleUseMessage.heading,
|
||||
subheading: localSurvey.singleUse.subheading ?? singleUseMessage.subheading,
|
||||
});
|
||||
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
|
||||
}
|
||||
|
||||
if (localSurvey.verifyEmail) {
|
||||
setVerifyEmailSurveyDetails({
|
||||
name: localSurvey.verifyEmail.name!,
|
||||
@@ -302,6 +378,81 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Single User Survey Options */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="singleUserSurveyOptions"
|
||||
isChecked={!!localSurvey.singleUse?.enabled}
|
||||
onToggle={handleSingleUseSurveyToggle}
|
||||
title="Single-Use Survey Links"
|
||||
description="Allow only 1 response per survey link."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
<div className="w-full cursor-pointer items-center bg-slate-50">
|
||||
<div className="row mb-2 flex cursor-default items-center space-x-2">
|
||||
<Label htmlFor="howItWorks">How it works</Label>
|
||||
</div>
|
||||
<ul className="mb-3 ml-4 cursor-default list-inside list-disc space-y-1">
|
||||
<li className="text-sm text-slate-600">
|
||||
Blocks survey if the survey URL has no Single Use Id (suId).
|
||||
</li>
|
||||
<li className="text-sm text-slate-600">
|
||||
Blocks survey if a submission with the Single Use Id (suId) in the URL exists already.
|
||||
</li>
|
||||
</ul>
|
||||
<Label htmlFor="headline">‘Link Used’ Message</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
defaultValue={singleUseMessage.heading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
|
||||
/>
|
||||
|
||||
<Label htmlFor="headline">Subheading</Label>
|
||||
<Input
|
||||
className="mb-4 mt-2 bg-white"
|
||||
id="subheading"
|
||||
name="subheading"
|
||||
defaultValue={singleUseMessage.subheading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ subheading: e.target.value })}
|
||||
/>
|
||||
<Label htmlFor="headline">URL Encryption</Label>
|
||||
<div>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="mt-2 flex items-center space-x-1 ">
|
||||
<Switch
|
||||
id="encryption-switch"
|
||||
checked={singleUseEncryption}
|
||||
onCheckedChange={hangleSingleUseEncryptionToggle}
|
||||
disabled={!isEncryptionKeySet}
|
||||
/>
|
||||
<Label htmlFor="encryption-label">
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-normal text-slate-600">
|
||||
Enable encryption of Single Use Id (suId) in survey URL.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!isEncryptionKeySet && (
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
FORMBRICKS_ENCRYPTION_KEY needs to be set to enable this feature.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Verify Email Section */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="verifyEmailBeforeSubmission"
|
||||
isChecked={verifyEmailToggle}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SettingsViewProps {
|
||||
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
isEncryptionKeySet: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsView({
|
||||
@@ -22,6 +23,7 @@ export default function SettingsView({
|
||||
setLocalSurvey,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isEncryptionKeySet,
|
||||
}: SettingsViewProps) {
|
||||
return (
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
@@ -41,7 +43,11 @@ export default function SettingsView({
|
||||
actionClasses={actionClasses}
|
||||
/>
|
||||
|
||||
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
<ResponseOptionsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
isEncryptionKeySet={isEncryptionKeySet}
|
||||
/>
|
||||
|
||||
<RecontactOptionsCard
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface SurveyEditorProps {
|
||||
environment: TEnvironment;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
isEncryptionKeySet: boolean;
|
||||
}
|
||||
|
||||
export default function SurveyEditor({
|
||||
@@ -28,6 +29,7 @@ export default function SurveyEditor({
|
||||
environment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isEncryptionKeySet,
|
||||
}: SurveyEditorProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -88,6 +90,7 @@ export default function SurveyEditor({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isEncryptionKeySet={isEncryptionKeySet}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { validateQuestion } from "./Validation";
|
||||
import { deleteSurveyAction, surveyMutateAction } from "./actions";
|
||||
import { deleteSurveyAction, updateSurveyAction } from "./actions";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurveyWithAnalytics;
|
||||
@@ -143,7 +143,7 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
|
||||
try {
|
||||
await surveyMutateAction({ ...strippedSurvey });
|
||||
await updateSurveyAction({ ...strippedSurvey });
|
||||
router.refresh();
|
||||
setIsMutatingSurvey(false);
|
||||
toast.success("Changes saved.");
|
||||
@@ -155,6 +155,7 @@ export default function SurveyMenuBar({
|
||||
} else {
|
||||
router.push(`/environments/${environment.id}/surveys`);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -241,7 +242,7 @@ export default function SurveyMenuBar({
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
return;
|
||||
}
|
||||
await surveyMutateAction({ ...localSurvey, status: "inProgress" });
|
||||
await updateSurveyAction({ ...localSurvey, status: "inProgress" });
|
||||
router.refresh();
|
||||
setIsMutatingSurvey(false);
|
||||
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { deleteSurvey, updateSurvey } from "@formbricks/lib/services/survey";
|
||||
import { deleteSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export async function updateSurveyAction(survey: TSurvey): Promise<TSurvey> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, survey.id);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
export async function surveyMutateAction(survey: TSurvey): Promise<TSurvey> {
|
||||
return await updateSurvey(survey);
|
||||
}
|
||||
|
||||
export async function deleteSurveyAction(surveyId: string) {
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
import React from "react";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { FORMBRICKS_ENCRYPTION_KEY, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import SurveyEditor from "./SurveyEditor";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/survey/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
@@ -17,6 +17,7 @@ export default async function SurveysEditPage({ params }) {
|
||||
getActionClasses(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
]);
|
||||
const isEncryptionKeySet = !!FORMBRICKS_ENCRYPTION_KEY;
|
||||
if (!survey || !environment || !actionClasses || !attributeClasses || !product) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export default async function SurveysEditPage({ params }) {
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isEncryptionKeySet={isEncryptionKeySet}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { createSurvey } from "@formbricks/lib/services/survey";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: Partial<TSurvey>) {
|
||||
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");
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: any) {
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
import { TSurveyInput } from "@formbricks/types/v1/surveys";
|
||||
|
||||
type TemplateList = {
|
||||
environmentId: string;
|
||||
@@ -77,7 +78,7 @@ export default function TemplateList({
|
||||
...activeTemplate.preset,
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
};
|
||||
} as Partial<TSurveyInput>;
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { createSurvey } from "@formbricks/lib/services/survey";
|
||||
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: Partial<TSurvey>) {
|
||||
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");
|
||||
|
||||
export async function createSurveyAction(environmentId: string, surveyBody: any) {
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
}
|
||||
|
||||
@@ -2062,4 +2062,5 @@ export const minimalSurvey: TSurvey = {
|
||||
surveyClosedMessage: {
|
||||
enabled: false,
|
||||
},
|
||||
singleUse: null,
|
||||
};
|
||||
|
||||
@@ -41,7 +41,11 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
|
||||
if (selectedObjective) {
|
||||
try {
|
||||
setIsProfileUpdating(true);
|
||||
const updatedProfile = { ...profile, objective: selectedObjective.id };
|
||||
const updatedProfile = {
|
||||
...profile,
|
||||
objective: selectedObjective.id,
|
||||
name: profile.name ?? undefined,
|
||||
};
|
||||
await updateProfileAction(updatedProfile);
|
||||
setIsProfileUpdating(false);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getFirstEnvironmentByUserId } from "@formbricks/lib/services/environment";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getProfile } from "@formbricks/lib/services/profile";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Onboarding from "./components/Onboarding";
|
||||
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
|
||||
import { getProfile } from "@formbricks/lib/services/profile";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const environment = await getEnvironmentByUser(session?.user);
|
||||
if (!session) {
|
||||
throw new Error("No session found");
|
||||
}
|
||||
const environment = await getFirstEnvironmentByUserId(session?.user.id);
|
||||
const profile = await getProfile(session?.user.id!);
|
||||
const product = await getProductByEnvironmentId(environment?.id!);
|
||||
|
||||
if (!environment || !profile || !product) {
|
||||
return <ErrorComponent />;
|
||||
throw new Error("Failed to get environment, profile, or product");
|
||||
}
|
||||
|
||||
return <Onboarding session={session} environmentId={environment?.id} profile={profile} product={product} />;
|
||||
return <Onboarding session={session} environmentId={environment.id} profile={profile} product={product} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writeData } from "@formbricks/lib/services/googleSheet";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TGoogleSheetIntegration, TIntegration } from "@formbricks/types/v1/integrations";
|
||||
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { InvalidInputError } from "@formbricks/types/v1/errors";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { createDisplay } from "@formbricks/lib/services/displays";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
|
||||
import { TDisplay, ZDisplayInput } from "@formbricks/types/v1/displays";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { updateResponse } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/v1/responses";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { InvalidInputError } from "@formbricks/types/v1/errors";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { createResponse } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { selectSurvey } from "@formbricks/lib/services/survey";
|
||||
import { selectSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { unstable_cache } from "next/cache";
|
||||
@@ -164,6 +164,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis
|
||||
})
|
||||
.map((survey) => ({
|
||||
...survey,
|
||||
singleUse: survey.singleUse ? JSON.parse(JSON.stringify(survey.singleUse)) : null,
|
||||
triggers: survey.triggers.map((trigger) => trigger.eventClass),
|
||||
attributeFilters: survey.attributeFilters.map((af) => ({
|
||||
...af,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
|
||||
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSurvey, updateSurvey, deleteSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurvey, updateSurvey, deleteSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TSurvey, ZSurveyInput } from "@formbricks/types/v1/surveys";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { responses } from "@/lib/api/response";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { createSurvey, getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { ZSurveyInput } from "@formbricks/types/v1/surveys";
|
||||
import { DatabaseError } from "@formbricks/types/v1/errors";
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/lib/email";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { populateEnvironment } from "@/lib/populate";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED, INVITE_DISABLED, SIGNUP_ENABLED } from "@formbricks/lib/constants";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { deleteInvite } from "@formbricks/lib/services/invite";
|
||||
import { createMembership } from "@formbricks/lib/services/membership";
|
||||
import { createProduct } from "@formbricks/lib/services/product";
|
||||
import { createProfile } from "@formbricks/lib/services/profile";
|
||||
import { createTeam } from "@formbricks/lib/services/team";
|
||||
import { NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
INTERNAL_SECRET,
|
||||
INVITE_DISABLED,
|
||||
SIGNUP_ENABLED,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let { inviteToken, ...user } = await request.json();
|
||||
@@ -22,7 +19,6 @@ export async function POST(request: Request) {
|
||||
let inviteId;
|
||||
|
||||
try {
|
||||
let data: Prisma.UserCreateArgs;
|
||||
let invite;
|
||||
|
||||
if (inviteToken) {
|
||||
@@ -40,92 +36,35 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Invalid invite ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
data = {
|
||||
data: {
|
||||
...user,
|
||||
memberships: {
|
||||
create: {
|
||||
accepted: true,
|
||||
role: invite.role,
|
||||
team: {
|
||||
connect: {
|
||||
id: invite.teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
data: {
|
||||
...user,
|
||||
memberships: {
|
||||
create: [
|
||||
{
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
team: {
|
||||
create: {
|
||||
name: `${user.name}'s Team`,
|
||||
products: {
|
||||
create: [
|
||||
{
|
||||
name: "My Product",
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: "production",
|
||||
...populateEnvironment,
|
||||
},
|
||||
{
|
||||
type: "development",
|
||||
...populateEnvironment,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// create a user and assign him to the team
|
||||
|
||||
type UserWithMemberships = Prisma.UserGetPayload<{ include: { memberships: true } }>;
|
||||
|
||||
const userData = (await prisma.user.create({
|
||||
...data,
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
// TODO: This is a hack to get the correct types (casting), we should find a better way to do this
|
||||
})) as UserWithMemberships;
|
||||
|
||||
const teamId = userData.memberships[0].teamId;
|
||||
|
||||
if (teamId) {
|
||||
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
const profile = await createProfile(user);
|
||||
await createMembership(invite.teamId, profile.id, {
|
||||
accepted: true,
|
||||
role: invite.role,
|
||||
});
|
||||
}
|
||||
|
||||
if (inviteId) {
|
||||
sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
|
||||
await prisma.invite.delete({ where: { id: inviteId } });
|
||||
}
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
await sendVerificationEmail(profile);
|
||||
}
|
||||
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
await sendVerificationEmail(userData);
|
||||
await sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
|
||||
await deleteInvite(inviteId);
|
||||
|
||||
return NextResponse.json(profile);
|
||||
} else {
|
||||
const team = await createTeam({
|
||||
name: `${user.name}'s Team`,
|
||||
});
|
||||
await createProduct(team.id, { name: "My Product" });
|
||||
const profile = await createProfile(user);
|
||||
await createMembership(team.id, profile.id, { role: "owner", accepted: true });
|
||||
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
await sendVerificationEmail(profile);
|
||||
}
|
||||
return NextResponse.json(profile);
|
||||
}
|
||||
return NextResponse.json(userData);
|
||||
} catch (e) {
|
||||
if (e.code === "P2002") {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ClientLogout from "@/app/ClientLogout";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
|
||||
import { getFirstEnvironmentByUserId } from "@formbricks/lib/services/environment";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -18,7 +18,10 @@ export default async function Home() {
|
||||
|
||||
let environment;
|
||||
try {
|
||||
environment = await getEnvironmentByUser(session?.user);
|
||||
environment = await getFirstEnvironmentByUserId(session?.user.id);
|
||||
if (!environment) {
|
||||
throw new Error("No environment found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error getting environment", error);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import VerifyEmail from "@/app/s/[surveyId]/VerifyEmail";
|
||||
import { getPrefillResponseData } from "@/app/s/[surveyId]/prefilling";
|
||||
import { TResponseData } from "@formbricks/types/v1/responses";
|
||||
import { TResponse, TResponseData } from "@formbricks/types/v1/responses";
|
||||
import SurveyLinkUsed from "@/app/s/[surveyId]/SurveyLinkUsed";
|
||||
|
||||
interface LinkSurveyProps {
|
||||
survey: TSurvey;
|
||||
@@ -20,6 +21,8 @@ interface LinkSurveyProps {
|
||||
personId?: string;
|
||||
emailVerificationStatus?: string;
|
||||
prefillAnswer?: string;
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: TResponse;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
@@ -29,11 +32,15 @@ export default function LinkSurvey({
|
||||
personId,
|
||||
emailVerificationStatus,
|
||||
prefillAnswer,
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
webAppUrl,
|
||||
}: LinkSurveyProps) {
|
||||
const responseId = singleUseResponse?.id;
|
||||
const searchParams = useSearchParams();
|
||||
const isPreview = searchParams?.get("preview") === "true";
|
||||
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id));
|
||||
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
|
||||
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId));
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string>(survey.questions[0].id);
|
||||
const prefillResponseData: TResponseData | undefined = prefillAnswer
|
||||
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer)
|
||||
@@ -56,6 +63,13 @@ export default function LinkSurvey({
|
||||
[personId, webAppUrl]
|
||||
);
|
||||
const [autoFocus, setAutofocus] = useState(false);
|
||||
const hasFinishedSingleUseResponse = useMemo(() => {
|
||||
if (singleUseResponse && singleUseResponse.finished) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Not in an iframe, enable autofocus on input fields.
|
||||
useEffect(() => {
|
||||
@@ -68,6 +82,10 @@ export default function LinkSurvey({
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
}, [responseQueue, surveyState]);
|
||||
|
||||
if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) {
|
||||
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
|
||||
}
|
||||
|
||||
if (emailVerificationStatus && emailVerificationStatus !== "verified") {
|
||||
if (emailVerificationStatus === "fishy") {
|
||||
return <VerifyEmail survey={survey} isErrorComponent={true} />;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TSurveyClosedMessage } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { CheckCircleIcon, PauseCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { CheckCircleIcon, PauseCircleIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import footerLogo from "./footerlogo.svg";
|
||||
@@ -9,17 +9,19 @@ const SurveyInactive = ({
|
||||
status,
|
||||
surveyClosedMessage,
|
||||
}: {
|
||||
status: "paused" | "completed";
|
||||
status: "paused" | "completed" | "link invalid";
|
||||
surveyClosedMessage?: TSurveyClosedMessage | null;
|
||||
}) => {
|
||||
const icons = {
|
||||
paused: <PauseCircleIcon className="h-20 w-20" />,
|
||||
completed: <CheckCircleIcon className="h-20 w-20" />,
|
||||
"link invalid": <QuestionMarkCircleIcon className="h-20 w-20" />,
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
paused: "This free & open-source survey is temporarily paused.",
|
||||
completed: "This free & open-source survey has been closed.",
|
||||
"link invalid": "This survey can only be taken by invitation.",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -31,12 +33,11 @@ const SurveyInactive = ({
|
||||
{status === "completed" && surveyClosedMessage ? surveyClosedMessage.heading : `Survey ${status}.`}
|
||||
</h1>
|
||||
<p className="text-lg leading-10 text-gray-500">
|
||||
{" "}
|
||||
{status === "completed" && surveyClosedMessage
|
||||
? surveyClosedMessage.subheading
|
||||
: descriptions[status]}
|
||||
</p>
|
||||
{!(status === "completed" && surveyClosedMessage) && (
|
||||
{!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && (
|
||||
<Button variant="darkCTA" className="mt-2" href="https://formbricks.com">
|
||||
Create your own
|
||||
</Button>
|
||||
|
||||
35
apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx
Normal file
35
apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SurveySingleUse } from "@formbricks/types/surveys";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import footerLogo from "./footerlogo.svg";
|
||||
|
||||
type SurveyLinkUsedProps = {
|
||||
singleUseMessage: Omit<SurveySingleUse, "enabled"> | null;
|
||||
};
|
||||
|
||||
const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => {
|
||||
const defaultHeading = "The survey has already been answered.";
|
||||
const defaultSubheading = "You can only use this link once.";
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
|
||||
<div></div>
|
||||
<div className="flex flex-col items-center space-y-3 text-slate-300">
|
||||
<CheckCircleIcon className="h-20 w-20" />
|
||||
<h1 className="text-4xl font-bold text-slate-800">
|
||||
{!!singleUseMessage?.heading ? singleUseMessage?.heading : defaultHeading}
|
||||
</h1>
|
||||
<p className="text-lg leading-10 text-gray-500">
|
||||
{!!singleUseMessage?.subheading ? singleUseMessage?.subheading : defaultSubheading}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyLinkUsed;
|
||||
@@ -5,13 +5,30 @@ import SurveyInactive from "@/app/s/[surveyId]/SurveyInactive";
|
||||
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getOrCreatePersonByUserId } from "@formbricks/lib/services/person";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getEmailVerificationStatus } from "./helpers";
|
||||
import { checkValidity } from "@/app/s/[surveyId]/prefilling";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { validateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
export default async function LinkSurveyPage({ params, searchParams }) {
|
||||
interface LinkSurveyPageProps {
|
||||
params: {
|
||||
surveyId: string;
|
||||
};
|
||||
searchParams: {
|
||||
suId?: string;
|
||||
userId?: string;
|
||||
verify?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LinkSurveyPage({ params, searchParams }: LinkSurveyPageProps) {
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const suId = searchParams.suId;
|
||||
const isSingleUseSurvey = survey?.singleUse?.enabled;
|
||||
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
|
||||
|
||||
if (!survey || survey.type !== "link" || survey.status === "draft") {
|
||||
notFound();
|
||||
@@ -30,14 +47,41 @@ export default async function LinkSurveyPage({ params, searchParams }) {
|
||||
);
|
||||
}
|
||||
|
||||
let singleUseId: string | undefined = undefined;
|
||||
if (isSingleUseSurvey) {
|
||||
// check if the single use id is present for single use surveys
|
||||
if (!suId) {
|
||||
return <SurveyInactive status="link invalid" />;
|
||||
}
|
||||
|
||||
// if encryption is enabled, validate the single use id
|
||||
let validatedSingleUseId: string | undefined = undefined;
|
||||
if (isSingleUseSurveyEncrypted) {
|
||||
validatedSingleUseId = validateSurveySingleUseId(suId);
|
||||
if (!validatedSingleUseId) {
|
||||
return <SurveyInactive status="link invalid" />;
|
||||
}
|
||||
}
|
||||
// if encryption is disabled, use the suId as is
|
||||
singleUseId = validatedSingleUseId ?? suId;
|
||||
}
|
||||
|
||||
let singleUseResponse: TResponse | undefined = undefined;
|
||||
if (isSingleUseSurvey) {
|
||||
singleUseResponse = (await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined;
|
||||
}
|
||||
|
||||
// verify email: Check if the survey requires email verification
|
||||
let emailVerificationStatus;
|
||||
let emailVerificationStatus: string | undefined = undefined;
|
||||
if (survey.verifyEmail) {
|
||||
const token =
|
||||
searchParams && Object.keys(searchParams).length !== 0 && searchParams.hasOwnProperty("verify")
|
||||
? searchParams.verify
|
||||
: undefined;
|
||||
emailVerificationStatus = await getEmailVerificationStatus(survey.id, token);
|
||||
|
||||
if (token) {
|
||||
emailVerificationStatus = await getEmailVerificationStatus(survey.id, token);
|
||||
}
|
||||
}
|
||||
|
||||
// get product and person
|
||||
@@ -59,6 +103,8 @@ export default async function LinkSurveyPage({ params, searchParams }) {
|
||||
personId={person?.id}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
|
||||
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
|
||||
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import SurveyNavBarName from "@/components/shared/SurveyNavBarName";
|
||||
import Link from "next/link";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
|
||||
interface SecondNavbarProps {
|
||||
tabs: { id: string; label: string; href: string; icon?: React.ReactNode }[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
@@ -44,7 +44,7 @@ export default function SurveyStatusDropdown({
|
||||
disabled={isStatusChangeDisabled}
|
||||
onValueChange={(value) => {
|
||||
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
|
||||
surveyMutateAction({ ...survey, status: castedValue })
|
||||
updateSurveyAction({ ...survey, status: castedValue })
|
||||
.then(() => {
|
||||
toast.success(
|
||||
value === "inProgress"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTeam } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { createTeamAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -15,13 +14,12 @@ interface CreateTeamModalProps {
|
||||
|
||||
export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps) {
|
||||
const router = useRouter();
|
||||
const { profile } = useProfile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const submitTeam = async (data) => {
|
||||
setLoading(true);
|
||||
const newTeam = await createTeam(data.name, (profile as any).id);
|
||||
const newTeam = await createTeamAction(data.name);
|
||||
|
||||
toast.success("Team created successfully!");
|
||||
router.push(`/teams/${newTeam.id}`);
|
||||
|
||||
@@ -70,6 +70,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
FORMBRICKS_ENCRYPTION_KEY: z.string().length(24).or(z.string().length(0)).optional(),
|
||||
},
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
@@ -114,6 +115,7 @@ export const env = createEnv({
|
||||
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||
FORMBRICKS_ENCRYPTION_KEY: process.env.FORMBRICKS_ENCRYPTION_KEY,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
SURVEY_BASE_URL: process.env.SURVEY_BASE_URL,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { EventType } from "@prisma/client";
|
||||
export const populateEnvironment = {
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{ name: "userId", description: "The internal ID of the person", type: EventType.automatic },
|
||||
{ name: "email", description: "The email of the person", type: EventType.automatic },
|
||||
],
|
||||
},
|
||||
};
|
||||
33
apps/web/lib/singleUseSurveys.ts
Normal file
33
apps/web/lib/singleUseSurveys.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { FORMBRICKS_ENCRYPTION_KEY } from "@formbricks/lib/constants";
|
||||
import { decryptAES128, encryptAES128 } from "@formbricks/lib/crypto";
|
||||
import cuid2 from "@paralleldrive/cuid2";
|
||||
|
||||
// generate encrypted single use id for the survey
|
||||
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
|
||||
const cuid = cuid2.createId();
|
||||
if (!isEncrypted) {
|
||||
return cuid;
|
||||
}
|
||||
if (!FORMBRICKS_ENCRYPTION_KEY) {
|
||||
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||
}
|
||||
const encryptedCuid = encryptAES128(FORMBRICKS_ENCRYPTION_KEY, cuid);
|
||||
return encryptedCuid;
|
||||
};
|
||||
|
||||
// validate the survey single use id
|
||||
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
|
||||
if (!FORMBRICKS_ENCRYPTION_KEY) {
|
||||
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||
}
|
||||
try {
|
||||
const decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
|
||||
if (cuid2.isCuid(decryptedCuid)) {
|
||||
return decryptedCuid;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -64,6 +64,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
singleUseId: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -109,6 +109,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
singleUseId: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getSessionUser, hasEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EnvironmentType } from "@prisma/client";
|
||||
import { populateEnvironment } from "@/lib/populate";
|
||||
import { createProduct } from "@formbricks/lib/services/product";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -93,29 +92,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
// Create a new product and associate it with the current team
|
||||
const newProduct = await prisma.product.create({
|
||||
data: {
|
||||
name,
|
||||
team: {
|
||||
connect: { id: environment.product.teamId },
|
||||
},
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: EnvironmentType.production,
|
||||
...populateEnvironment,
|
||||
},
|
||||
{
|
||||
type: EnvironmentType.development,
|
||||
...populateEnvironment,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
const newProduct = await createProduct(environment.product.teamId, { name });
|
||||
|
||||
const firstEnvironment = newProduct.environments[0];
|
||||
res.json(firstEnvironment);
|
||||
|
||||
@@ -146,6 +146,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,6 +98,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
body.surveyClosedMessage = prismaClient.JsonNull;
|
||||
}
|
||||
|
||||
if (!body.singleUse) {
|
||||
body.singleUse = prismaClient.JsonNull;
|
||||
}
|
||||
|
||||
if (!body.verifyEmail) {
|
||||
body.verifyEmail = prismaClient.JsonNull;
|
||||
}
|
||||
@@ -230,6 +234,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
data.surveyClosedMessage = prismaClient.JsonNull;
|
||||
}
|
||||
|
||||
if (data.singleUse === null) {
|
||||
data.singleUse = prismaClient.JsonNull;
|
||||
}
|
||||
|
||||
if (data.verifyEmail === null) {
|
||||
data.verifyEmail = prismaClient.JsonNull;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TResponsePersonAttributes, TResponseData, TResponseMeta } from "@formbr
|
||||
import {
|
||||
TSurveyClosedMessage,
|
||||
TSurveyQuestions,
|
||||
TSurveySingleUse,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyVerifyEmail,
|
||||
} from "@formbricks/types/v1/surveys";
|
||||
@@ -20,6 +21,7 @@ declare global {
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyThankYouCard = TSurveyThankYouCard;
|
||||
export type SurveyClosedMessage = TSurveyClosedMessage;
|
||||
export type SurveySingleUse = TSurveySingleUse;
|
||||
export type SurveyVerifyEmail = TSurveyVerifyEmail;
|
||||
export type UserNotificationSettings = TUserNotificationSettings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[surveyId,singleUseId]` on the table `Response` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Response" ADD COLUMN "singleUseId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "singleUse" JSONB DEFAULT '{"enabled": false, "isEncrypted": true}';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Response_surveyId_singleUseId_key" ON "Response"("surveyId", "singleUseId");
|
||||
@@ -113,6 +113,10 @@ model Response {
|
||||
/// @zod.custom(imports.ZResponsePersonAttributes)
|
||||
/// [ResponsePersonAttributes]
|
||||
personAttributes Json?
|
||||
// singleUseId, used to prevent multiple responses
|
||||
singleUseId String?
|
||||
|
||||
@@unique([surveyId, singleUseId])
|
||||
}
|
||||
|
||||
model ResponseNote {
|
||||
@@ -245,6 +249,9 @@ model Survey {
|
||||
/// @zod.custom(imports.ZSurveyClosedMessage)
|
||||
/// [SurveyClosedMessage]
|
||||
surveyClosedMessage Json?
|
||||
/// @zod.custom(imports.ZSurveySingleUse)
|
||||
/// [SurveySingleUse]
|
||||
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
|
||||
/// @zod.custom(imports.ZSurveyVerifyEmail)
|
||||
/// [SurveyVerifyEmail]
|
||||
verifyEmail Json?
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
ZSurveyThankYouCard,
|
||||
ZSurveyClosedMessage,
|
||||
ZSurveyVerifyEmail,
|
||||
ZSurveySingleUse,
|
||||
} from "@formbricks/types/v1/surveys";
|
||||
|
||||
export { ZUserNotificationSettings } from "@formbricks/types/v1/users";
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SURVEY_BASE_URL = env.SURVEY_BASE_URL ? env.SURVEY_BASE_URL + "/" :
|
||||
|
||||
// Other
|
||||
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || "";
|
||||
export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined;
|
||||
export const CRON_SECRET = env.CRON_SECRET;
|
||||
export const DEFAULT_BRAND_COLOR = "#64748b";
|
||||
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
import { createHash } from "crypto";
|
||||
import { createHash, createCipheriv, createDecipheriv } from "crypto";
|
||||
|
||||
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
// create an aes128 encryption function
|
||||
export const encryptAES128 = (encryptionKey: string, data: string): string => {
|
||||
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
|
||||
let encrypted = cipher.update(data, "utf-8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
return encrypted;
|
||||
};
|
||||
// create an aes128 decryption function
|
||||
export const decryptAES128 = (encryptionKey: string, data: string): string => {
|
||||
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
|
||||
let decrypted = cipher.update(data, "hex", "utf-8");
|
||||
decrypted += cipher.final("utf-8");
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import "server-only";
|
||||
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { getResponse, getResponseCacheTag } from "./service";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { getSurvey } from "../services/survey";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { getSurvey } from "../survey/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { getResponse, getResponseCacheTag } from "./service";
|
||||
|
||||
export const canUserAccessResponse = async (userId: string, responseId: string): Promise<boolean> =>
|
||||
await unstable_cache(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/error
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { cache } from "react";
|
||||
import { getPerson, transformPrismaPerson } from "../services/person";
|
||||
import { captureTelemetry } from "../telemetry";
|
||||
@@ -28,6 +29,7 @@ const responseSelection = {
|
||||
data: true,
|
||||
meta: true,
|
||||
personAttributes: true,
|
||||
singleUseId: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -115,6 +117,41 @@ export const getResponsesByPersonId = async (personId: string): Promise<Array<TR
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponseBySingleUseId = cache(
|
||||
async (surveyId: string, singleUseId?: string): Promise<TResponse | null> => {
|
||||
validateInputs([surveyId, ZId], [singleUseId, z.string()]);
|
||||
try {
|
||||
if (!singleUseId) {
|
||||
return null;
|
||||
}
|
||||
const responsePrisma = await prisma.response.findUnique({
|
||||
where: {
|
||||
surveyId_singleUseId: { surveyId, singleUseId },
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createResponse = async (responseInput: Partial<TResponseInput>): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput.partial()]);
|
||||
captureTelemetry("response created");
|
||||
@@ -143,6 +180,7 @@ export const createResponse = async (responseInput: Partial<TResponseInput>): Pr
|
||||
personAttributes: person?.attributes,
|
||||
}),
|
||||
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
|
||||
singleUseId: responseInput.singleUseId,
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
@@ -72,7 +72,12 @@ export class ResponseQueue {
|
||||
await updateResponse(responseUpdate, this.surveyState.responseId, this.config.apiHost);
|
||||
} else {
|
||||
const response = await createResponse(
|
||||
{ ...responseUpdate, surveyId: this.surveyState.surveyId, personId: this.config.personId || null },
|
||||
{
|
||||
...responseUpdate,
|
||||
surveyId: this.surveyState.surveyId,
|
||||
personId: this.config.personId || null,
|
||||
singleUseId: this.surveyState.singleUseId || null,
|
||||
},
|
||||
this.config.apiHost
|
||||
);
|
||||
if (this.surveyState.displayId) {
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { z } from "zod";
|
||||
import { Prisma, EnvironmentType } from "@prisma/client";
|
||||
import type {
|
||||
TEnvironment,
|
||||
TEnvironmentCreateInput,
|
||||
TEnvironmentUpdateInput,
|
||||
} from "@formbricks/types/v1/environment";
|
||||
import {
|
||||
ZEnvironment,
|
||||
ZEnvironmentCreateInput,
|
||||
ZEnvironmentUpdateInput,
|
||||
ZId,
|
||||
} from "@formbricks/types/v1/environment";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors";
|
||||
import type { TEnvironment, TEnvironmentId, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
|
||||
import { populateEnvironment } from "../utils/createDemoProductHelpers";
|
||||
import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1/environment";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { unstable_cache, revalidateTag } from "next/cache";
|
||||
import { EventType, Prisma } from "@prisma/client";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const getEnvironmentCacheTag = (environmentId: string) => `environments-${environmentId}`;
|
||||
export const getEnvironmentsCacheTag = (productId: string) => `products-${productId}-environments`;
|
||||
@@ -125,86 +134,84 @@ export const updateEnvironment = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getEnvironmentByUser = async (user: any): Promise<TEnvironment | TEnvironmentId | null> => {
|
||||
const firstMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!firstMembership) {
|
||||
// create a new team and return environment
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
user: { connect: { id: user.id } },
|
||||
team: {
|
||||
create: {
|
||||
name: `${user.name}'s Team`,
|
||||
products: {
|
||||
create: {
|
||||
name: "My Product",
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: EnvironmentType.production,
|
||||
...populateEnvironment,
|
||||
},
|
||||
{
|
||||
type: EnvironmentType.development,
|
||||
...populateEnvironment,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
products: {
|
||||
include: {
|
||||
environments: true,
|
||||
export const getFirstEnvironmentByUserId = async (userId: string): Promise<TEnvironment | null> => {
|
||||
validateInputs([userId, ZId]);
|
||||
let environmentPrisma;
|
||||
try {
|
||||
environmentPrisma = await prisma.environment.findFirst({
|
||||
where: {
|
||||
type: "production",
|
||||
product: {
|
||||
team: {
|
||||
memberships: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
const environment = membership.team.products[0].environments[0];
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const environment = ZEnvironment.parse(environmentPrisma);
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of environment failed");
|
||||
}
|
||||
|
||||
const firstProduct = await prisma.product.findFirst({
|
||||
where: {
|
||||
teamId: firstMembership.teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (firstProduct === null) {
|
||||
return null;
|
||||
}
|
||||
const firstEnvironment = await prisma.environment.findFirst({
|
||||
where: {
|
||||
productId: firstProduct.id,
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (firstEnvironment === null) {
|
||||
return null;
|
||||
}
|
||||
return firstEnvironment;
|
||||
};
|
||||
|
||||
export const createEnvironment = async (
|
||||
productId: string,
|
||||
environmentInput: Partial<TEnvironmentCreateInput>
|
||||
): Promise<TEnvironment> => {
|
||||
validateInputs([productId, ZId], [environmentInput, ZEnvironmentCreateInput]);
|
||||
|
||||
return await prisma.environment.create({
|
||||
data: {
|
||||
type: environmentInput.type || "development",
|
||||
product: { connect: { id: productId } },
|
||||
widgetSetupCompleted: environmentInput.widgetSetupCompleted || false,
|
||||
eventClasses: {
|
||||
create: populateEnvironment.eventClasses,
|
||||
},
|
||||
attributeClasses: {
|
||||
create: populateEnvironment.attributeClasses,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const populateEnvironment = {
|
||||
eventClasses: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
],
|
||||
attributeClasses: [
|
||||
{ name: "userId", description: "The internal ID of the person", type: EventType.automatic },
|
||||
{ name: "email", description: "The email of the person", type: EventType.automatic },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -62,6 +62,26 @@ export const getMembershipsByUserId = cache(async (userId: string): Promise<TMem
|
||||
return memberships;
|
||||
});
|
||||
|
||||
export const createMembership = async (
|
||||
teamId: string,
|
||||
userId: string,
|
||||
data: Partial<TMembership>
|
||||
): Promise<TMembership> => {
|
||||
try {
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
teamId,
|
||||
accepted: data.accepted,
|
||||
role: data.role as TMembership["role"],
|
||||
},
|
||||
});
|
||||
|
||||
return membership;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
export const updateMembership = async (
|
||||
userId: string,
|
||||
teamId: string,
|
||||
|
||||
@@ -9,11 +9,9 @@ import { Prisma } from "@prisma/client";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { cache } from "react";
|
||||
import { z } from "zod";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { EnvironmentType } from "@prisma/client";
|
||||
import { EventType } from "@prisma/client";
|
||||
import { getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { createEnvironment, getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment";
|
||||
|
||||
export const getProductsCacheTag = (teamId: string): string => `teams-${teamId}-products`;
|
||||
const getProductCacheTag = (environmentId: string): string => `environments-${environmentId}-product`;
|
||||
@@ -35,34 +33,6 @@ const selectProduct = {
|
||||
environments: true,
|
||||
};
|
||||
|
||||
const populateEnvironment = {
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: EventType.automatic,
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{ name: "userId", description: "The internal ID of the person", type: EventType.automatic },
|
||||
{ name: "email", description: "The email of the person", type: EventType.automatic },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const getProducts = async (teamId: string): Promise<TProduct[]> =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
@@ -135,6 +105,7 @@ export const updateProduct = async (
|
||||
inputProduct: Partial<TProductUpdateInput>
|
||||
): Promise<TProduct> => {
|
||||
validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput.partial()]);
|
||||
const { environments, ...data } = inputProduct;
|
||||
let updatedProduct;
|
||||
try {
|
||||
updatedProduct = await prisma.product.update({
|
||||
@@ -142,7 +113,10 @@ export const updateProduct = async (
|
||||
id: productId,
|
||||
},
|
||||
data: {
|
||||
...inputProduct,
|
||||
...data,
|
||||
environments: {
|
||||
connect: environments?.map((environment) => ({ id: environment.id })) ?? [],
|
||||
},
|
||||
},
|
||||
select: selectProduct,
|
||||
});
|
||||
@@ -210,43 +184,35 @@ export const deleteProduct = cache(async (productId: string): Promise<TProduct>
|
||||
return product;
|
||||
});
|
||||
|
||||
export const createProduct = async (environmentId: string, productName: string): Promise<TProduct> => {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
product: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Invalid environment");
|
||||
export const createProduct = async (
|
||||
teamId: string,
|
||||
productInput: Partial<TProductUpdateInput>
|
||||
): Promise<TProduct> => {
|
||||
if (!productInput.name) {
|
||||
throw new ValidationError("Product Name is required");
|
||||
}
|
||||
const { environments, ...data } = productInput;
|
||||
|
||||
const newProduct = await prisma.product.create({
|
||||
let product = await prisma.product.create({
|
||||
data: {
|
||||
name: productName,
|
||||
team: {
|
||||
connect: { id: environment.product.teamId },
|
||||
},
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: EnvironmentType.production,
|
||||
...populateEnvironment,
|
||||
},
|
||||
{
|
||||
type: EnvironmentType.development,
|
||||
...populateEnvironment,
|
||||
},
|
||||
],
|
||||
},
|
||||
...data,
|
||||
name: productInput.name,
|
||||
teamId,
|
||||
},
|
||||
select: selectProduct,
|
||||
});
|
||||
|
||||
return newProduct;
|
||||
const devEnvironment = await createEnvironment(product.id, {
|
||||
type: "development",
|
||||
});
|
||||
|
||||
const prodEnvironment = await createEnvironment(product.id, {
|
||||
type: "production",
|
||||
});
|
||||
|
||||
product = await updateProduct(product.id, {
|
||||
environments: [devEnvironment, prodEnvironment],
|
||||
});
|
||||
|
||||
return product;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,12 @@ import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/memberships";
|
||||
import { TProfile, TProfileUpdateInput, ZProfileUpdateInput } from "@formbricks/types/v1/profile";
|
||||
import {
|
||||
TProfile,
|
||||
TProfileCreateInput,
|
||||
TProfileUpdateInput,
|
||||
ZProfileUpdateInput,
|
||||
} from "@formbricks/types/v1/profile";
|
||||
import { MembershipRole, Prisma } from "@prisma/client";
|
||||
import { unstable_cache, revalidateTag } from "next/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
@@ -149,6 +154,19 @@ const deleteUser = async (userId: string): Promise<TProfile> => {
|
||||
return profile;
|
||||
};
|
||||
|
||||
export const createProfile = async (data: TProfileCreateInput): Promise<TProfile> => {
|
||||
validateInputs([data, ZProfileUpdateInput]);
|
||||
const profile = await prisma.user.create({
|
||||
data: data,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
revalidateTag(getProfileByEmailCacheTag(profile.email));
|
||||
revalidateTag(getProfileCacheTag(profile.id));
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
// function to delete a user's profile including teams
|
||||
export const deleteProfile = async (userId: string): Promise<void> => {
|
||||
validateInputs([userId, ZId]);
|
||||
|
||||
@@ -107,7 +107,20 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise<TTe
|
||||
}
|
||||
)();
|
||||
|
||||
export const updateTeam = async (teamId: string, data: TTeamUpdateInput): Promise<TTeam> => {
|
||||
export const createTeam = async (teamInput: TTeamUpdateInput): Promise<TTeam> => {
|
||||
try {
|
||||
const team = await prisma.team.create({
|
||||
data: teamInput,
|
||||
select,
|
||||
});
|
||||
|
||||
return team;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTeam = async (teamId: string, data: Partial<TTeamUpdateInput>): Promise<TTeam> => {
|
||||
try {
|
||||
const updatedTeam = await prisma.team.update({
|
||||
where: {
|
||||
|
||||
24
packages/lib/survey/auth.ts
Normal file
24
packages/lib/survey/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { getSurvey, getSurveyCacheTag } from "./service";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
export const canUserAccessSurvey = async (userId: string, surveyId: string): Promise<boolean> =>
|
||||
await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [userId, ZId]);
|
||||
|
||||
if (!userId) return false;
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) throw new Error("Survey not found");
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
[`users-${userId}-surveys-${surveyId}`],
|
||||
{ revalidate: 30 * 60, tags: [getSurveyCacheTag(surveyId)] }
|
||||
)(); // 30 minutes
|
||||
@@ -15,22 +15,15 @@ import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { captureTelemetry } from "../telemetry";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { getDisplaysCacheTag } from "./displays";
|
||||
import { getDisplaysCacheTag } from "../services/displays";
|
||||
import { getResponsesCacheTag } from "../response/service";
|
||||
|
||||
// surveys cache key and tags
|
||||
const getSurveysCacheKey = (environmentId: string): string => `environments-${environmentId}-surveys`;
|
||||
const getSurveysCacheTag = (environmentId: string): string => `environments-${environmentId}-surveys`;
|
||||
|
||||
// survey cache key and tags
|
||||
export const getSurveyCacheKey = (surveyId: string): string => `surveys-${surveyId}`;
|
||||
export const getSurveyCacheTag = (surveyId: string): string => `surveys-${surveyId}`;
|
||||
|
||||
// survey with analytics cache key
|
||||
const getSurveysWithAnalyticsCacheKey = (environmentId: string): string =>
|
||||
`environments-${environmentId}-surveysWithAnalytics`;
|
||||
const getSurveyWithAnalyticsCacheKey = (surveyId: string): string => `surveyWithAnalytics-${surveyId}`;
|
||||
|
||||
export const selectSurvey = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@@ -50,6 +43,7 @@ export const selectSurvey = {
|
||||
verifyEmail: true,
|
||||
redirectUrl: true,
|
||||
surveyClosedMessage: true,
|
||||
singleUse: true,
|
||||
triggers: {
|
||||
select: {
|
||||
eventClass: {
|
||||
@@ -144,7 +138,7 @@ export const getSurveyWithAnalytics = async (surveyId: string): Promise<TSurveyW
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
},
|
||||
[getSurveyWithAnalyticsCacheKey(surveyId)],
|
||||
[`surveyWithAnalytics-${surveyId}`],
|
||||
{
|
||||
tags: [getSurveyCacheTag(surveyId), getDisplaysCacheTag(surveyId), getResponsesCacheTag(surveyId)],
|
||||
revalidate: 60 * 30,
|
||||
@@ -203,7 +197,7 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
},
|
||||
[getSurveyCacheKey(surveyId)],
|
||||
[`surveys-${surveyId}`],
|
||||
{
|
||||
tags: [getSurveyCacheTag(surveyId)],
|
||||
revalidate: 60 * 30,
|
||||
@@ -328,7 +322,7 @@ export const getSurveys = async (environmentId: string): Promise<TSurvey[]> => {
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
},
|
||||
[getSurveysCacheKey(environmentId)],
|
||||
[`environments-${environmentId}-surveys`],
|
||||
{
|
||||
tags: [getSurveysCacheTag(environmentId)],
|
||||
revalidate: 60 * 30,
|
||||
@@ -392,7 +386,7 @@ export const getSurveysWithAnalytics = async (environmentId: string): Promise<TS
|
||||
throw new ValidationError("Data validation of survey failed");
|
||||
}
|
||||
},
|
||||
[getSurveysWithAnalyticsCacheKey(environmentId)],
|
||||
[`environments-${environmentId}-surveysWithAnalytics`],
|
||||
{
|
||||
tags: [getSurveysCacheTag(environmentId)], // TODO: add tags for displays and responses
|
||||
}
|
||||
@@ -5,9 +5,12 @@ export class SurveyState {
|
||||
displayId: string | null = null;
|
||||
surveyId: string;
|
||||
responseAcc: TResponseUpdate = { finished: false, data: {} };
|
||||
singleUseId: string | null;
|
||||
|
||||
constructor(surveyId: string) {
|
||||
constructor(surveyId: string, singleUseId?: string, responseId?: string) {
|
||||
this.surveyId = surveyId;
|
||||
this.singleUseId = singleUseId ?? null;
|
||||
this.responseId = responseId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,7 +25,11 @@ export class SurveyState {
|
||||
* Get a copy of the current state
|
||||
*/
|
||||
copy() {
|
||||
const copyInstance = new SurveyState(this.surveyId);
|
||||
const copyInstance = new SurveyState(
|
||||
this.surveyId,
|
||||
this.singleUseId ?? undefined,
|
||||
this.responseId ?? undefined
|
||||
);
|
||||
copyInstance.responseId = this.responseId;
|
||||
copyInstance.responseAcc = this.responseAcc;
|
||||
return copyInstance;
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface SurveyClosedMessage {
|
||||
subheading?: string;
|
||||
}
|
||||
|
||||
export interface SurveySingleUse {
|
||||
enabled: boolean;
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
}
|
||||
|
||||
export interface VerifyEmail {
|
||||
name?: string;
|
||||
subheading?: string;
|
||||
@@ -39,6 +45,7 @@ export interface Survey {
|
||||
surveyClosedMessage: SurveyClosedMessage | null;
|
||||
verifyEmail: VerifyEmail | null;
|
||||
closeOnDate: Date | null;
|
||||
singleUse: SurveySingleUse | null;
|
||||
_count: { responses: number | null } | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,4 +44,12 @@ export const ZActionClassInput = z.object({
|
||||
type: z.enum(["code", "noCode"]),
|
||||
});
|
||||
|
||||
export const ZActionClassAutomaticInput = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["automatic"]),
|
||||
});
|
||||
|
||||
export type TActionClassAutomaticInput = z.infer<typeof ZActionClassAutomaticInput>;
|
||||
|
||||
export type TActionClassInput = z.infer<typeof ZActionClassInput>;
|
||||
|
||||
@@ -22,12 +22,20 @@ export const ZAttributeClassInput = z.object({
|
||||
environmentId: z.string(),
|
||||
});
|
||||
|
||||
export const ZAttributeClassAutomaticInput = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
type: z.enum(["automatic"]),
|
||||
});
|
||||
|
||||
export const ZAttributeClassUpdateInput = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TAttributeClassAutomaticInput = z.infer<typeof ZAttributeClassAutomaticInput>;
|
||||
|
||||
export type TAttributeClassUpdateInput = z.infer<typeof ZAttributeClassUpdateInput>;
|
||||
|
||||
export type TAttributeClassInput = z.infer<typeof ZAttributeClassInput>;
|
||||
|
||||
@@ -25,4 +25,11 @@ export const ZEnvironmentUpdateInput = z.object({
|
||||
|
||||
export const ZId = z.string().cuid2();
|
||||
|
||||
export const ZEnvironmentCreateInput = z.object({
|
||||
type: z.enum(["development", "production"]).optional(),
|
||||
widgetSetupCompleted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TEnvironmentCreateInput = z.infer<typeof ZEnvironmentCreateInput>;
|
||||
|
||||
export type TEnvironmentUpdateInput = z.infer<typeof ZEnvironmentUpdateInput>;
|
||||
|
||||
@@ -11,7 +11,7 @@ export const ZProduct = z.object({
|
||||
highlightBorderColor: z
|
||||
.string()
|
||||
.regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/)
|
||||
.nullish(),
|
||||
.nullable(),
|
||||
recontactDays: z.number().int(),
|
||||
formbricksSignature: z.boolean(),
|
||||
placement: z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]),
|
||||
@@ -26,7 +26,6 @@ export const ZProductUpdateInput = ZProduct.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environments: true,
|
||||
});
|
||||
|
||||
export type TProductUpdateInput = z.infer<typeof ZProductUpdateInput>;
|
||||
|
||||
@@ -21,11 +21,21 @@ export const ZProfile = z.object({
|
||||
export type TProfile = z.infer<typeof ZProfile>;
|
||||
|
||||
export const ZProfileUpdateInput = z.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
onboardingCompleted: z.boolean(),
|
||||
role: ZRole.nullable(),
|
||||
objective: ZObjective.nullable(),
|
||||
name: z.string().nullish(),
|
||||
email: z.string().optional(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
role: ZRole.optional(),
|
||||
objective: ZObjective.optional(),
|
||||
});
|
||||
|
||||
export type TProfileUpdateInput = z.infer<typeof ZProfileUpdateInput>;
|
||||
|
||||
export const ZProfileCreateInput = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
role: ZRole.optional(),
|
||||
objective: ZObjective.optional(),
|
||||
});
|
||||
|
||||
export type TProfileCreateInput = z.infer<typeof ZProfileCreateInput>;
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ZResponse = z.object({
|
||||
notes: z.array(ZResponseNote),
|
||||
tags: z.array(ZTag),
|
||||
meta: ZResponseMeta.nullable(),
|
||||
singleUseId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TResponse = z.infer<typeof ZResponse>;
|
||||
@@ -60,6 +61,7 @@ export type TResponse = z.infer<typeof ZResponse>;
|
||||
export const ZResponseInput = z.object({
|
||||
surveyId: z.string().cuid2(),
|
||||
personId: z.string().cuid2().nullable(),
|
||||
singleUseId: z.string().nullable().optional(),
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
meta: z
|
||||
|
||||
@@ -17,6 +17,17 @@ export const ZSurveyClosedMessage = z
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
export const ZSurveySingleUse = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
heading: z.optional(z.string()),
|
||||
subheading: z.optional(z.string()),
|
||||
isEncrypted: z.boolean(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export type TSurveySingleUse = z.infer<typeof ZSurveySingleUse>;
|
||||
|
||||
export const ZSurveyVerifyEmail = z
|
||||
.object({
|
||||
name: z.optional(z.string()),
|
||||
@@ -259,6 +270,7 @@ export const ZSurvey = z.object({
|
||||
autoComplete: z.number().nullable(),
|
||||
closeOnDate: z.date().nullable(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
|
||||
singleUse: ZSurveySingleUse.nullable(),
|
||||
verifyEmail: ZSurveyVerifyEmail.nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const ZTeam = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -10,6 +9,12 @@ export const ZTeam = z.object({
|
||||
stripeCustomerId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TTeamUpdateInput = Prisma.TeamUpdateInput;
|
||||
export const ZTeamUpdateInput = z.object({
|
||||
name: z.string(),
|
||||
plan: z.enum(["free", "pro"]).optional(),
|
||||
stripeCustomerId: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type TTeamUpdateInput = z.infer<typeof ZTeamUpdateInput>;
|
||||
|
||||
export type TTeam = z.infer<typeof ZTeam>;
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_URL",
|
||||
"FORMBRICKS_ENCRYPTION_KEY",
|
||||
"IMPRINT_URL",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SURVEY_BASE_URL",
|
||||
|
||||
Reference in New Issue
Block a user