Compare commits

..

1 Commits

Author SHA1 Message Date
Sudhanshu Pandey
c62a4a5121 Update ecs-deployment.yml 2024-01-31 15:38:01 -05:00
81 changed files with 2065 additions and 3157 deletions

View File

@@ -12,8 +12,8 @@
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": ["dbaeumer.vscode-eslint"],
},
"extensions": ["dbaeumer.vscode-eslint"]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
@@ -25,5 +25,5 @@
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"remoteUser": "node"
}

View File

@@ -32,6 +32,7 @@ Fixes # (issue)
- [ ] Removed all `console.logs`
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
- [ ] My changes don't cause any responsiveness issues
- [ ] First PR at Formbricks? [Please sign the CLA!](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx) Without it we wont be able to merge it 🙏
### Appreciated

View File

@@ -1,4 +1,4 @@
name: Build formbricks-com
name: Build
on:
workflow_call:
jobs:
@@ -11,10 +11,10 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Node.js 20.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 20.x
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@v2

View File

@@ -1,4 +1,4 @@
name: Build web
name: Build
on:
workflow_call:
jobs:

View File

@@ -15,7 +15,7 @@ env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: formbricks/formbricks-experimental
IMAGE_NAME: formbricks-experimental
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
jobs:
@@ -66,16 +66,14 @@ jobs:
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}
# Build and push Docker image with Buildx
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@v1
env:
env:
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
with:
project: tw0fqmsx3c
@@ -83,7 +81,6 @@ jobs:
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -93,18 +90,16 @@ jobs:
DATABASE_URL=${{ env.DATABASE_URL }}
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
NEXT_PUBLIC_SENTRY_DSN=${{ env.NEXT_PUBLIC_SENTRY_DSN }}
- name: Sign the images with GitHub OIDC Token
- name: Sign the published Docker image
env:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
run: |
images=""
for tag in ${TAGS}; do
images+="${tag}@${DIGEST} "
done
cosign sign --yes ${images}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}${DIGEST}
deploy:
needs: build
runs-on: ubuntu-latest
@@ -126,7 +121,7 @@ jobs:
with:
task-definition: task-definition.json
container-name: prod-webapp-container
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1

View File

@@ -42,4 +42,6 @@ If you are at all unsure, just raise it as an enhancement issue first and tell u
To be able to keep working on Formbricks over the coming years, we need to collect a CLA from all relevant contributors.
Once you open a PR, you will get a message from the CLA bot to fill out the form. Please note that we can only get your contribution merged when we have a CLA signed by you.
Please note that we can only get your contribution merged when we have a CLA signed by you.
To access the CLA form, please click [here](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx)

View File

@@ -53,11 +53,12 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
{templates
.filter((template) => selectedFilter === ALL_CATEGORY_NAME || template.category === selectedFilter)
.map((template: TTemplate) => (
<div
key={template.name}
<button
type="button"
onClick={() => {
onTemplateClick(template); // Pass the 'template' object instead of 'activeTemplate'
}}
key={template.name}
className={cn(
activeTemplate?.name === template.name && "ring-brand ring-2",
"duration-120 group relative rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105 dark:bg-slate-700"
@@ -70,7 +71,7 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
{template.name}
</h3>
<p className="text-left text-xs text-slate-600 dark:text-slate-400">{template.description}</p>
</div>
</button>
))}
</div>
</main>

View File

@@ -1,6 +1,6 @@
import { createId } from "@paralleldrive/cuid2";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurveyHiddenFields, TSurveyQuestionType } from "@formbricks/types/surveys";
import { TTemplate } from "@formbricks/types/templates";
import {
AppPieChartIcon,
@@ -14,7 +14,6 @@ import {
DashboardIcon,
DogChaserIcon,
DoorIcon,
EmailIcon,
FeedbackIcon,
GaugeSpeedFastIcon,
HeartCommentIcon,
@@ -39,6 +38,11 @@ const welcomeCardDefault = {
showResponseCount: false,
};
const hiddenFieldsDefault: TSurveyHiddenFields = {
enabled: true,
fieldIds: [],
};
export const customSurvey: TTemplate = {
name: "Start from scratch",
description: "Create a survey without template.",
@@ -1227,12 +1231,12 @@ export const templates: TTemplate[] = [
},
{
name: "Improve Newsletter Content",
icon: EmailIcon,
category: "Growth",
description: "Find out how your subscribers like your newsletter content.",
objectives: ["increase_conversion", "sharpen_marketing_messaging"],
description: "Find out how your subscribers like your newsletter content.",
preset: {
name: "Improve Newsletter Content",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1273,11 +1277,8 @@ export const templates: TTemplate[] = [
dismissButtonLabel: "Find your own friends",
},
],
welcomeCard: welcomeCardDefault,
thankYouCard: thankYouCardDefault,
hiddenFields: {
enabled: false,
},
hiddenFields: hiddenFieldsDefault,
},
},
];

View File

@@ -81,7 +81,7 @@ export default function BestPracticeNavigation() {
},
{
name: "Improve Newsletter Content",
name: "Improve Newsletter Cotent",
href: "/improve-newsletter-content",
status: true,
icon: FeedbackIcon,

View File

@@ -1,11 +1,10 @@
import AuthorBox from "@/components/shared/AuthorBox";
import LayoutMdx from "@/components/shared/LayoutMdx";
import Image from "next/image";
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
import LayoutMdx from "@/components/shared/LayoutMdx";
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
import PackagesFolderImage from "./formbricks-packages-folder.png";
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
import GitpodImage from "./setup-formbricks-via-gitpod.png";
import PackagesFolderImage from "./formbricks-packages-folder.png";
import AuthorBox from "@/components/shared/AuthorBox";
export const meta = {
title: "Join the FormTribe 🔥",
@@ -17,7 +16,7 @@ export const meta = {
tags: ["Open-Source", "No-Code", "Formbricks", "Geting started", "Welcome guide"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"} />
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"}/>
<Image src={HeaderImage} alt="Title Image" className="w-full rounded-lg" />
@@ -55,9 +54,9 @@ To get up and running we have 2 options: Gitpod and local.
With Gitpod you can run all of Formbricks in the cloud. With one click you can start coding right away in your browser:
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks)
[Read more in our docs](https://formbricks.com/docs/contributing/setup#gitpod-guide)
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
#### Run on a local machine

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { Button } from "@formbricks/ui/Button";
import { FooterLogo } from "../shared/Logo";
import { FooterLogo } from "../../components/shared/Logo";
export default function HeaderLight() {
const plausible = usePlausible();

View File

@@ -1,5 +1,5 @@
import Footer from "../shared/Footer";
import MetaInformation from "../shared/MetaInformation";
import Footer from "../../components/shared/Footer";
import MetaInformation from "../../components/shared/MetaInformation";
import HeaderLight from "./HeaderLight";
interface LayoutProps {

View File

@@ -1,12 +1,12 @@
import Layout from "@/components/demo/LayoutLight";
import DemoView from "@/components/dummyUI/DemoView";
import LayoutWaitlist from "@/pages/demo/LayoutLight";
export default function DemoPage() {
return (
<Layout
<LayoutWaitlist
title="Formbricks Demo"
description="Play around with our pre-defined 30+ templates and them to kick-start your survey & experience management.">
<DemoView />
</Layout>
</LayoutWaitlist>
);
}

View File

@@ -1,16 +1,20 @@
"use server";
import { Team } from "@prisma/client";
import { Prisma as prismaClient } from "@prisma/client/";
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
export const createShortUrlAction = async (url: string) => {
@@ -40,28 +44,193 @@ export async function createTeamAction(teamName: string): Promise<Team> {
accepted: true,
});
const product = await createProduct(newTeam.id, {
await createProduct(newTeam.id, {
name: "My Product",
});
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
},
weeklySummary: {
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
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 duplicatedSurvey = await duplicateSurvey(environmentId, surveyId);
return duplicatedSurvey;
}
export async function copyToOtherEnvironmentAction(
environmentId: string,
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,
environmentId,
},
include: {
triggers: {
include: {
actionClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
},
});
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
name: trigger.actionClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.actionClass.description,
type: trigger.actionClass.type,
noCodeConfig: trigger.actionClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
: undefined,
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
}
}
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: targetEnvironmentId,
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
}
}
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
styling: existingSurvey.styling ?? prismaClient.JsonNull,
},
});
surveyCache.revalidate({
id: newSurvey.id,
environmentId: targetEnvironmentId,
});
return newSurvey;
}
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");
const survey = await getSurvey(surveyId);
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
};
export const createProductAction = async (environmentId: string, productName: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
@@ -75,20 +244,6 @@ export const createProductAction = async (environmentId: string, productName: st
const product = await createProduct(team.id, {
name: productName,
});
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
},
weeklySummary: {
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
// get production environment
const productionEnvironment = product.environments.find((environment) => environment.type === "production");

View File

@@ -504,7 +504,7 @@ export default function Navigation({
)}
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await signOut();
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">

View File

@@ -98,11 +98,8 @@ export const leaveTeamAction = async (teamId: string) => {
};
export const createInviteTokenAction = async (inviteId: string) => {
const invite = await getInvite(inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
const inviteToken = createInviteToken(inviteId, invite.email, {
const { email } = await getInvite(inviteId);
const inviteToken = createInviteToken(inviteId, email, {
expiresIn: "7d",
});

View File

@@ -2,8 +2,8 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -13,7 +13,9 @@ export async function updateNotificationSettingsAction(notificationSettings: TUs
throw new AuthorizationError("Not authenticated");
}
await updateUser(session.user.id, {
notificationSettings,
// update user with notification settings
await prisma.user.update({
where: { id: session.user.id },
data: { notificationSettings },
});
}

View File

@@ -1,49 +1,27 @@
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
import { Membership } from "../types";
import { Membership, User } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
user: User;
environmentId: string;
autoDisableNotificationType: string;
autoDisableNotificationElementId: string;
}
export default function EditAlerts({
memberships,
user,
environmentId,
autoDisableNotificationType,
autoDisableNotificationElementId,
}: EditAlertsProps) {
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
return (
<>
{memberships.map((membership) => (
<>
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
<div className="col-span-3 flex items-center space-x-3">
<div className="rounded-full bg-slate-100 p-1">
<UsersIcon className="h-6 w-7 text-slate-600" />
</div>
<p className="font-semibold text-slate-800">{membership.team.name}</p>
</div>
<div className="col-span-3 flex items-center justify-end pr-2">
<p className="pr-4 text-sm text-slate-600">Auto-subscribe to new surveys</p>
<NotificationSwitch
surveyOrProductOrTeamId={membership.team.id}
notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedTeamIds"}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
<div className="mb-5 flex items-center space-x-3 font-semibold">
<div className="rounded-full bg-slate-100 p-1">
<UsersIcon className="h-6 w-7 text-slate-600" />
</div>
<p className="text-slate-800">{membership.team.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
@@ -79,11 +57,9 @@ export default function EditAlerts({
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProductOrTeamId={survey.id}
notificationSettings={user.notificationSettings!}
surveyOrProductId={survey.id}
notificationSettings={user.notificationSettings}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</div>
</div>

View File

@@ -1,14 +1,12 @@
import { UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { Membership, User } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
user: User;
environmentId: string;
}
@@ -36,8 +34,8 @@ export default function EditWeeklySummary({ memberships, user, environmentId }:
<div className="col-span-2">{product?.name}</div>
<div className="col-span-1 flex items-center justify-center">
<NotificationSwitch
surveyOrProductOrTeamId={product.id}
notificationSettings={user.notificationSettings!}
surveyOrProductId={product.id}
notificationSettings={user.notificationSettings}
notificationType={"weeklySummary"}
/>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -9,90 +10,35 @@ import { Switch } from "@formbricks/ui/Switch";
import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProductOrTeamId: string;
surveyOrProductId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary" | "unsubscribedTeamIds";
autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string;
notificationType: "alert" | "weeklySummary";
}
export function NotificationSwitch({
surveyOrProductOrTeamId,
surveyOrProductId,
notificationSettings,
notificationType,
autoDisableNotificationType,
autoDisableNotificationElementId,
}: NotificationSwitchProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const isChecked =
notificationType === "unsubscribedTeamIds"
? !notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)
: notificationSettings[notificationType][surveyOrProductOrTeamId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedTeamIds") {
const unsubscribedTeamIds = updatedNotificationSettings.unsubscribedTeamIds ?? [];
if (unsubscribedTeamIds.includes(surveyOrProductOrTeamId)) {
updatedNotificationSettings.unsubscribedTeamIds = unsubscribedTeamIds.filter(
(id) => id !== surveyOrProductOrTeamId
);
} else {
updatedNotificationSettings.unsubscribedTeamIds = [...unsubscribedTeamIds, surveyOrProductOrTeamId];
}
} else {
updatedNotificationSettings[notificationType][surveyOrProductOrTeamId] =
!updatedNotificationSettings[notificationType][surveyOrProductOrTeamId];
}
await updateNotificationSettingsAction(updatedNotificationSettings);
setIsLoading(false);
};
useEffect(() => {
if (
autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrProductOrTeamId &&
isChecked
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType][surveyOrProductOrTeamId] === true) {
handleSwitchChange();
toast.success("You will not receive any more emails for responses on this survey!", {
id: "notification-switch",
});
}
break;
case "unsubscribedTeamIds":
if (!notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)) {
handleSwitchChange();
toast.success("You will not be auto-subscribed to this team's surveys anymore!", {
id: "notification-switch",
});
}
break;
default:
break;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Switch
id="notification-switch"
aria-label={`toggle notification settings for ${notificationType}`}
checked={isChecked}
aria-label="toggle notification settings"
checked={notificationSettings[notificationType][surveyOrProductId]}
disabled={isLoading}
onCheckedChange={async () => {
await handleSwitchChange();
toast.success("Notification settings updated", { id: "notification-switch" });
setIsLoading(true);
// update notificiation settings
const updatedNotificationSettings = { ...notificationSettings };
updatedNotificationSettings[notificationType][surveyOrProductId] =
!updatedNotificationSettings[notificationType][surveyOrProductId];
await updateNotificationSettingsAction(notificationSettings);
setIsLoading(false);
toast.success(`Notification settings updated`, { id: "notification-switch" });
router.refresh();
}}
/>
);

View File

@@ -3,24 +3,42 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { getUser } from "@formbricks/lib/user/service";
import { TUserNotificationSettings } from "@formbricks/types/user";
import SettingsTitle from "../components/SettingsTitle";
import EditAlerts from "./components/EditAlerts";
import EditWeeklySummary from "./components/EditWeeklySummary";
import IntegrationsTip from "./components/IntegrationsTip";
import type { Membership } from "./types";
import type { Membership, User } from "./types";
function setCompleteNotificationSettings(
async function getUser(userId: string | undefined): Promise<User> {
if (!userId) {
throw new Error("Unauthorized");
}
const userData = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
notificationSettings: true,
},
});
if (!userData) {
throw new Error("Unauthorized");
}
const user = JSON.parse(JSON.stringify(userData)); // hack to remove the JsonValue type from the notificationSettings
return user;
}
function cleanNotificationSettings(
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings {
const newNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedTeamIds: notificationSettings.unsubscribedTeamIds || [],
};
) {
const newNotificationSettings = { alert: {}, weeklySummary: {} };
for (const membership of memberships) {
for (const product of membership.team.products) {
// set default values for weekly summary
@@ -77,22 +95,13 @@ async function getMemberships(userId: string): Promise<Membership[]> {
return memberships;
}
export default async function ProfileSettingsPage({ params, searchParams }) {
export default async function ProfileSettingsPage({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new Error("User not found");
}
if (user?.notificationSettings) {
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
}
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
return (
<div>
@@ -100,16 +109,11 @@ export default async function ProfileSettingsPage({ params, searchParams }) {
<SettingsCard
title="Email alerts (Surveys)"
description="Set up an alert to get an email on new responses.">
<EditAlerts
memberships={memberships}
user={user}
environmentId={params.environmentId}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
<EditAlerts memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
beta
title="Weekly summary (Products)"
description="Stay up-to-date with a Weekly every Monday.">
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />

View File

@@ -55,7 +55,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
try {
setDeleting(true);
await deleteUserAction();
await signOut({ callbackUrl: "/auth/login" });
await signOut();
await formbricksLogout();
} catch (error) {
toast.error("Something went wrong");

View File

@@ -27,7 +27,10 @@ export default function SurveyStatusDropdown({
<>
{survey.status === "draft" ? (
<div className="flex items-center">
<p className="text-sm italic text-slate-600">Draft</p>
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
</div>
) : (
<Select

View File

@@ -257,12 +257,12 @@ export default function ResponseOptionsCard({
surveyClosedMessage.subheading,
]);
const toggleAutocomplete = () => {
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey = { ...localSurvey, autoComplete: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey = { ...localSurvey, autoComplete: Math.max(25, responseCount + 5) };
const updatedSurvey = { ...localSurvey, autoComplete: 25 };
setLocalSurvey(updatedSurvey);
}
};
@@ -310,7 +310,7 @@ export default function ResponseOptionsCard({
<AdvancedOptionToggle
htmlId="closeOnNumberOfResponse"
isChecked={autoComplete}
onToggle={toggleAutocomplete}
onToggle={handleCheckMark}
title="Close survey on response limit"
description="Automatically close the survey after a certain number of responses."
childBorder={true}>
@@ -411,7 +411,7 @@ export default function ResponseOptionsCard({
htmlId="singleUserSurveyOptions"
isChecked={!!localSurvey.singleUse?.enabled}
onToggle={handleSingleUseSurveyToggle}
title="Single-use survey links"
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">
@@ -507,7 +507,7 @@ export default function ResponseOptionsCard({
htmlId="protectSurveyWithPin"
isChecked={isPinProtectionEnabled}
onToggle={handleProtectSurveyWithPinToggle}
title="Protect survey with a PIN"
title="Protect Survey with a PIN"
description="Only users who have the PIN can access the survey."
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">

View File

@@ -223,6 +223,14 @@ export default function SurveyMenuBar({
return false;
}
/*
Check whether the count for autocomplete responses is not less
than the current count of accepted response and also it is not set to 0
*/
if ((survey.autoComplete && responseCount >= survey.autoComplete) || survey?.autoComplete === 0) {
return false;
}
return true;
};

View File

@@ -1,14 +1,19 @@
"use client";
import {
copyToOtherEnvironmentAction,
deleteSurveyAction,
duplicateSurveyAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import {
ArrowUpOnSquareStackIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
EyeIcon,
LinkIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/solid";
import { MoreVertical } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -16,17 +21,15 @@ import toast from "react-hot-toast";
import type { TEnvironment } from "@formbricks/types/environment";
import type { TSurvey } from "@formbricks/types/surveys";
import { DeleteDialog } from "../../DeleteDialog";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../DropdownMenu";
import LoadingSpinner from "../../LoadingSpinner";
import { copyToOtherEnvironmentAction, deleteSurveyAction, duplicateSurveyAction } from "../actions";
} from "@formbricks/ui/DropdownMenu";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
interface SurveyDropDownMenuProps {
environmentId: string;
@@ -49,12 +52,11 @@ export default function SurveyDropDownMenu({
}: SurveyDropDownMenuProps) {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const router = useRouter();
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey.id, webAppUrl]);
const handleDeleteSurvey = async (survey: TSurvey) => {
const handleDeleteSurvey = async (survey) => {
setLoading(true);
try {
await deleteSurveyAction(survey.id);
@@ -67,7 +69,7 @@ export default function SurveyDropDownMenu({
setLoading(false);
};
const duplicateSurveyAndRefresh = async (surveyId: string) => {
const duplicateSurveyAndRefresh = async (surveyId) => {
setLoading(true);
try {
await duplicateSurveyAction(environmentId, surveyId);
@@ -79,7 +81,7 @@ export default function SurveyDropDownMenu({
setLoading(false);
};
const copyToOtherEnvironment = async (surveyId: string) => {
const copyToOtherEnvironment = async (surveyId) => {
setLoading(true);
try {
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
@@ -103,11 +105,11 @@ export default function SurveyDropDownMenu({
}
return (
<>
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="rounded-lg border p-2 hover:bg-slate-50">
<div>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
@@ -127,9 +129,7 @@ export default function SurveyDropDownMenu({
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
onClick={async () => {
duplicateSurveyAndRefresh(survey.id);
}}>
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
@@ -145,9 +145,7 @@ export default function SurveyDropDownMenu({
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
@@ -159,9 +157,7 @@ export default function SurveyDropDownMenu({
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
@@ -174,27 +170,23 @@ export default function SurveyDropDownMenu({
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<div
className="flex w-full cursor-pointer items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
const previewUrl = singleUseId
<Link
className="flex w-full items-center"
href={
singleUseId
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
: `/s/${survey.id}?preview=true`;
window.open(previewUrl, "_blank");
}}>
: `/s/${survey.id}?preview=true`
}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onClick={() => {
navigator.clipboard.writeText(
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
);
@@ -212,9 +204,7 @@ export default function SurveyDropDownMenu({
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,143 @@
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import type { TEnvironment } from "@formbricks/types/environment";
import { Badge } from "@formbricks/ui/Badge";
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const session = await getServerSession(authOptions);
const product = await getProductByEnvironmentId(environmentId);
const team = await getTeamByEnvironmentId(environmentId);
if (!session) {
throw new Error("Session not found");
}
if (!product) {
throw new Error("Product not found");
}
if (!team) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
const isSurveyCreationDeletionDisabled = isViewer;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const surveys = await getSurveys(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
if (surveys.length === 0) {
return (
<SurveyStarter
environmentId={environmentId}
environment={environment}
product={product}
user={session.user}
/>
);
}
return (
<>
<ul className="grid place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
{!isSurveyCreationDeletionDisabled && (
<Link href={`/environments/${environmentId}/surveys/templates`}>
<li className="col-span-1 h-56">
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
Create Survey
</div>
</div>
</li>
</Link>
)}
{surveys
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
.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`
}
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" && (
<>
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
</>
)}
{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!}
webAppUrl={WEBAPP_URL}
singleUseId={singleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
/>
</div>
</div>
</div>
</li>
);
})}
</ul>
<UsageAttributesUpdater numSurveys={surveys.length} />
</>
);
}

View File

@@ -31,12 +31,11 @@ export default function SurveyStarter({
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const autoComplete = surveyType === "web" ? 50 : null;
const augmentedTemplate: TSurveyInput = {
const augmentedTemplate = {
...template.preset,
type: surveyType,
autoComplete: autoComplete || undefined,
createdBy: user.id,
};
autoComplete,
} as TSurveyInput;
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);

View File

@@ -1,71 +1,18 @@
import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
import SurveysList from "@formbricks/ui/SurveysList";
import SurveysList from "./components/SurveyList";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
const session = await getServerSession(authOptions);
const product = await getProductByEnvironmentId(params.environmentId);
const team = await getTeamByEnvironmentId(params.environmentId);
if (!session) {
throw new Error("Session not found");
}
if (!product) {
throw new Error("Product not found");
}
if (!team) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const surveys = await getSurveys(params.environmentId);
const environments = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
return (
<ContentWrapper className="flex h-full flex-col justify-between">
{surveys.length > 0 ? (
<SurveysList
environment={environment}
surveys={surveys}
otherEnvironment={otherEnvironment}
isViewer={isViewer}
WEBAPP_URL={WEBAPP_URL}
userId={session.user.id}
/>
) : (
<SurveyStarter
environmentId={params.environmentId}
environment={environment}
product={product}
user={session.user}
/>
)}
<SurveysList environmentId={params.environmentId} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
</ContentWrapper>
);

View File

@@ -70,12 +70,11 @@ export default function TemplateList({
setLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const autoComplete = surveyType === "web" ? 50 : null;
const augmentedTemplate: TSurveyInput = {
const augmentedTemplate = {
...activeTemplate.preset,
type: surveyType,
autoComplete,
createdBy: user.id,
};
} as TSurveyInput;
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};

View File

@@ -2505,7 +2505,6 @@ export const minimalSurvey: TSurvey = {
name: "Minimal Survey",
type: "web",
environmentId: "someEnvId1",
createdBy: null,
status: "draft",
attributeFilters: [],
displayOption: "displayOnce",

View File

@@ -55,7 +55,7 @@ export default function Onboarding({ session, environmentId, user, product }: On
setIsLoading(true);
try {
const updatedProfile = { onboardingCompleted: true };
const updatedProfile = { ...user, onboardingCompleted: true };
await updateUserAction(updatedProfile);
if (environmentId) {

View File

@@ -1,12 +1,6 @@
import { Button } from "@formbricks/ui/Button";
interface ContentLayoutProps {
headline: string;
description: string;
children?: React.ReactNode;
}
const ContentLayout = ({ headline, description, children }: ContentLayoutProps) => {
const ContentLayout = ({ headline, description, children }) => {
return (
<div className="flex h-screen">
<div className="m-auto flex flex-col gap-7 text-center text-slate-700">
@@ -63,17 +57,9 @@ export const ExpiredContent = () => {
return (
<ContentLayout
headline="Invite expired 😥"
description="Invites are valid for 7 days. Please request a new invite."
/>
);
};
export const InvitationNotFound = () => {
return (
<ContentLayout
headline="Invite not found 😥"
description="The invitation code cannot be found or has already been used."
/>
description="Invites are valid for 7 days. Please request a new invite.">
<div></div>
</ContentLayout>
);
};

View File

@@ -1,54 +1,48 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { sendInviteAcceptedEmail } from "@formbricks/lib/emails/emails";
import { env } from "@formbricks/lib/env.mjs";
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import {
ExpiredContent,
InvitationNotFound,
NotLoggedInContent,
RightAccountContent,
UsedContent,
WrongAccountContent,
} from "./components/InviteContentComponents";
export default async function InvitePage({ searchParams }) {
const session = await getServerSession(authOptions);
export default async function JoinTeam({ searchParams }) {
const currentUser = await getServerSession(authOptions);
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return <InvitationNotFound />;
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
if (isInviteExpired) {
if (!invite || isInviteExpired) {
return <ExpiredContent />;
} else if (invite.accepted) {
return <UsedContent />;
} else if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
} else if (!currentUser) {
const redirectUrl = env.NEXTAUTH_URL + "/invite?token=" + searchParams.token;
return <NotLoggedInContent email={email} token={searchParams.token} redirectUrl={redirectUrl} />;
} else if (session.user?.email !== email) {
} else if (currentUser.user?.email !== email) {
return <WrongAccountContent />;
} else {
await createMembership(invite.teamId, session.user.id, { accepted: true, role: invite.role });
await createMembership(invite.teamId, currentUser.user.id, { accepted: true, role: invite.role });
await deleteInvite(inviteId);
sendInviteAcceptedEmail(invite.creator.name ?? "", session.user?.name ?? "", invite.creator.email);
sendInviteAcceptedEmail(invite.creator.name ?? "", currentUser.user?.name ?? "", invite.creator.email);
return <RightAccountContent />;
}
} catch (e) {
console.error(e);
return <InvitationNotFound />;
return <ExpiredContent />;
}
}

View File

@@ -159,27 +159,30 @@ const createSurveyFields = (surveyResponses: SurveyResponse[]) => {
return surveyFields;
};
const notificationFooter = (environmentId: string) => {
const notificationFooter = () => {
return `
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
<p><i>To halt Weekly Updates, <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications">please turn them off</a> in your settings 🙏</i></p>
</div>
`;
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};
const createReminderNotificationBody = (notificationData: NotificationResponse) => {
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
return `
<p>Wed love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
<p style="font-weight: bold; padding-top:1em;">Dont let a week pass without learning about your users:</p>
<a class="button" href="${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
<br/>
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
${notificationFooter(notificationData.environmentId)}
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};
@@ -204,7 +207,7 @@ export const sendWeeklySummaryNotificationEmail = async (
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${notificationInsight(notificationData.insights)}
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
${notificationFooter(notificationData.environmentId)}
${notificationFooter()}
`),
});
};
@@ -228,7 +231,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${createReminderNotificationBody(notificationData)}
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
`),
});
};

View File

@@ -7,7 +7,6 @@ import { prisma } from "@formbricks/database";
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { sendResponseFinishedEmail } from "@formbricks/lib/emails/emails";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { ZPipelineInput } from "@formbricks/types/pipelines";
@@ -126,9 +125,6 @@ export async function POST(request: Request) {
return false;
});
// Exclude current response
const responseCount = await getResponseCountBySurveyId(surveyId);
if (usersWithNotifications.length > 0) {
// get survey
if (!surveyData) {
@@ -159,7 +155,7 @@ export async function POST(request: Request) {
// send email to all users
await Promise.all(
usersWithNotifications.map(async (user) => {
await sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount);
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
})
);
}

View File

@@ -23,11 +23,7 @@ export async function POST(request: Request): Promise<NextResponse> {
responseInput.personId = null;
}
const agent = UAParser(request.headers.get("user-agent"));
const country =
headers().get("CF-IPCountry") ||
headers().get("X-Vercel-IP-Country") ||
headers().get("CloudFront-Viewer-Country") ||
undefined;
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
if (!inputValidation.success) {

View File

@@ -46,11 +46,7 @@ export async function POST(request: Request, context: Context): Promise<NextResp
}
const agent = UAParser(request.headers.get("user-agent"));
const country =
headers().get("CF-IPCountry") ||
headers().get("X-Vercel-IP-Country") ||
headers().get("CloudFront-Viewer-Country") ||
undefined;
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!inputValidation.success) {

View File

@@ -9,7 +9,7 @@ import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createTeam, getTeam } from "@formbricks/lib/team/service";
import { createUser, updateUser } from "@formbricks/lib/user/service";
import { createUser } from "@formbricks/lib/user/service";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
@@ -76,22 +76,7 @@ export async function POST(request: Request) {
else {
const team = await createTeam({ name: user.name + "'s Team" });
await createMembership(team.id, user.id, { role: "owner", accepted: true });
const product = await createProduct(team.id, { name: "My Product" });
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
await createProduct(team.id, { name: "My Product" });
}
// send verification email amd return user
if (!EMAIL_VERIFICATION_DISABLED) {

View File

@@ -112,14 +112,6 @@ input[type="search"]::-ms-reveal {
display: none;
}
.surveyFilterDropdown[data-state="open"]{
background-color: #0f172a;
color: white;
}
.surveyFilterDropdown:hover * {
background-color: #0f172a;
color: white;
}
input[type='range']::-webkit-slider-thumb {
background: #0f172a;

View File

@@ -13,6 +13,3 @@ export const shareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/;
return regex.test(url);
};
export const isWebAppRoute = (url: string): boolean =>
url.startsWith("/environments") && url !== "/api/auth/signout";

View File

@@ -6,33 +6,14 @@ import {
} from "@/app/middleware/bucket";
import {
clientSideApiRoute,
isWebAppRoute,
loginRoute,
shareUrlRoute,
signupRoute,
} from "@/app/middleware/endpointValidator";
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
const loginUrl = new URL(
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
WEBAPP_URL
);
return NextResponse.redirect(loginUrl.href);
}
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
}
if (process.env.NODE_ENV !== "production") {
return NextResponse.next();
}
@@ -73,8 +54,5 @@ export const config = {
"/api/v1/js/actions",
"/api/v1/client/storage",
"/share/(.*)/:path",
"/environments/:path*",
"/api/auth/signout",
"/auth/login",
],
};

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "1.5.1",
"version": "1.5.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -27,9 +27,9 @@
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-email/components": "^0.0.14",
"@sentry/nextjs": "^7.99.0",
"@sentry/nextjs": "^7.98.0",
"@vercel/og": "^0.6.2",
"@vercel/speed-insights": "^1.0.9",
"@vercel/speed-insights": "^1.0.8",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.1",
"encoding": "^0.1.13",
@@ -37,25 +37,25 @@
"googleapis": "^131.0.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^10.2.0",
"lucide-react": "^0.321.0",
"lru-cache": "^10.1.0",
"lucide-react": "^0.315.0",
"mime": "^4.0.1",
"next": "14.1.0",
"nodemailer": "^6.9.9",
"nodemailer": "^6.9.8",
"otplib": "^12.0.1",
"posthog-js": "^1.104.4",
"posthog-js": "^1.102.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^2.0.0",
"react-hook-form": "^7.50.0",
"react-hook-form": "^7.49.3",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"sharp": "^0.33.2",
"ua-parser-js": "^1.0.37",
"webpack": "^5.90.1",
"webpack": "^5.90.0",
"xlsx": "^0.18.5"
},
"devDependencies": {

View File

@@ -63,14 +63,13 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is visible
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
await page.waitForTimeout(1000);
});
test("Admin checks Display", async ({ page }) => {
await login(page, email, password);
await page.getByRole("link", { name: "In-app Open options Product" }).click();
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 1 Display
@@ -121,7 +120,8 @@ test.describe("JS Package Test", async () => {
test("Admin validates Response", async ({ page }) => {
await login(page, email, password);
await page.getByRole("link", { name: "In-app Open options Product" }).click();
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 2 Displays

View File

@@ -40,7 +40,6 @@ export const skipOnboarding = async (page: Page): Promise<void> => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);

View File

@@ -32,13 +32,13 @@
"prepare": "husky install"
},
"devDependencies": {
"@playwright/test": "^1.41.2",
"@playwright/test": "^1.41.1",
"eslint-config-formbricks": "workspace:*",
"husky": "^9.0.10",
"lint-staged": "^15.2.1",
"husky": "^9.0.5",
"lint-staged": "^15.2.0",
"rimraf": "^5.0.5",
"tsx": "^4.7.0",
"turbo": "^1.12.2"
"turbo": "^1.11.3"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -64,6 +64,6 @@
},
"dependencies": {
"@changesets/cli": "^2.27.1",
"playwright": "^1.41.2"
"playwright": "^1.41.1"
}
}

View File

@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "createdBy" TEXT;
-- AddForeignKey
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,5 +0,0 @@
-- DropForeignKey
ALTER TABLE "Survey" DROP CONSTRAINT "Survey_createdBy_fkey";
-- AddForeignKey
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -25,15 +25,15 @@
"predev": "pnpm generate"
},
"dependencies": {
"@prisma/client": "^5.9.1",
"@prisma/extension-accelerate": "^0.6.3",
"@prisma/client": "^5.8.1",
"@prisma/extension-accelerate": "^0.6.2",
"dotenv-cli": "^7.3.0"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"prisma": "^5.9.1",
"prisma": "^5.8.1",
"prisma-dbml-generator": "^0.10.0",
"prisma-json-types-generator": "^3.0.3",
"zod": "^3.22.4",

View File

@@ -257,8 +257,6 @@ model Survey {
type SurveyType @default(web)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
creator User? @relation(fields: [createdBy], references: [id])
createdBy String?
status SurveyStatus @default(draft)
/// @zod.custom(imports.ZSurveyWelcomeCard)
/// [SurveyWelcomeCard]
@@ -566,7 +564,6 @@ model User {
/// @zod.custom(imports.ZUserNotificationSettings)
/// [UserNotificationSettings]
notificationSettings Json @default("{}")
surveys Survey[]
@@index([email])
}

View File

@@ -31,12 +31,17 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro
<div>
<Label>Role</Label>
<Select
defaultValue="admin"
value={value}
onValueChange={(v) => onChange(v as MembershipRole)}
disabled={!canDoRoleManagement}>
<SelectTrigger className="capitalize">
<SelectValue />
<SelectValue
placeholder={
<span className="text-slate-400">
{canDoRoleManagement ? "Select role" : "Select role (Pro Feature)"}
</span>
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>

View File

@@ -18,6 +18,6 @@
},
"dependencies": {
"@formbricks/lib": "workspace:*",
"stripe": "^14.14.0"
"stripe": "^14.13.0"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.5.1",
"version": "1.5.0",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
@@ -40,17 +40,17 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/core": "^7.23.7",
"@babel/preset-env": "^7.23.8",
"@babel/preset-typescript": "^7.23.3",
"@formbricks/api": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"isomorphic-fetch": "^3.0.0",

View File

@@ -248,22 +248,7 @@ export const authOptions: NextAuthOptions = {
...account,
userId: userProfile.id,
});
const product = await createProduct(team.id, { name: "My Product" });
const updatedNotificationSettings = {
...userProfile.notificationSettings,
alert: {
...userProfile.notificationSettings?.alert,
},
weeklySummary: {
...userProfile.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(userProfile.id, {
notificationSettings: updatedNotificationSettings,
});
await createProduct(team.id, { name: "My Product" });
return true;
}
}

View File

@@ -13,7 +13,6 @@ import {
} from "../constants";
import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt";
import { getQuestionResponseMapping } from "../responses";
import { getTeamByEnvironmentId } from "../team/service";
import { withEmailTemplate } from "./email-template";
const nodemailer = require("nodemailer");
@@ -162,52 +161,44 @@ export const sendResponseFinishedEmail = async (
email: string,
environmentId: string,
survey: { id: string; name: string; questions: TSurveyQuestion[] },
response: TResponse,
responseCount: number
response: TResponse
) => {
const personEmail = response.person?.attributes["email"];
const team = await getTeamByEnvironmentId(environmentId);
await sendEmail({
to: email,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail?.toString() || MAIL_FROM,
html: withEmailTemplate(`
<h1>Hey 👋</h1>
<p>Congrats, you received a new response to your survey!
Someone just completed your survey <strong>${survey.name}</strong><br/></p>
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
survey.name
}</strong><br/>
<hr/>
<hr/>
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
</div>`
)
.join("")}
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
</div>`
)
.join("")}
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">${responseCount > 1 ? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}` : `View survey summary`}</a>
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">View all responses</a>
<div class="tooltip">
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
<hr/>
<p><b>Don't want to get these emails?</b></p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}">this form</a>. <br/> Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}">all newly created forms</a>.</i></p></div>
<div class="tooltip">
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
`),
});
};

View File

@@ -121,7 +121,7 @@ export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
}
};
export const getInvite = async (inviteId: string): Promise<InviteWithCreator | null> => {
export const getInvite = async (inviteId: string): Promise<InviteWithCreator> => {
const invite = await unstable_cache(
async () => {
validateInputs([inviteId, ZString]);
@@ -140,18 +140,18 @@ export const getInvite = async (inviteId: string): Promise<InviteWithCreator | n
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
return invite;
},
[`getInvite-${inviteId}`],
{ tags: [inviteCache.tag.byId(inviteId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
return invite
? {
...formatDateFields(invite, ZInvite),
creator: invite.creator,
}
: null;
return {
...formatDateFields(invite, ZInvite),
creator: invite.creator,
};
};
export const resendInvite = async (inviteId: string): Promise<TInvite> => {

View File

@@ -14,9 +14,9 @@
"test": "jest -ci --coverage --no-cache"
},
"dependencies": {
"@aws-sdk/s3-presigned-post": "3.504.0",
"@aws-sdk/client-s3": "3.504.0",
"@aws-sdk/s3-request-presigner": "3.504.0",
"@aws-sdk/s3-presigned-post": "3.499.0",
"@aws-sdk/client-s3": "3.499.0",
"@aws-sdk/s3-request-presigner": "3.499.0",
"@t3-oss/env-nextjs": "^0.8.0",
"@formbricks/api": "*",
"@formbricks/database": "*",
@@ -27,10 +27,10 @@
"jsonwebtoken": "^9.0.2",
"markdown-it": "^14.0.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.5",
"nanoid": "^5.0.4",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.9",
"posthog-node": "^3.6.1",
"nodemailer": "^6.9.8",
"posthog-node": "^3.6.0",
"server-only": "^0.0.1",
"tailwind-merge": "^2.2.1"
},

View File

@@ -357,7 +357,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
});
if (!responsePrisma) {
return null;
throw new ResourceNotFoundError("Response", responseId);
}
const response: TResponse = {
@@ -382,12 +382,10 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
}
)();
return response
? ({
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
} as TResponse)
: null;
return {
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
} as TResponse;
};
export const getResponses = async (

View File

@@ -280,8 +280,7 @@ describe("Tests for getResponse service", () => {
it("Throws ResourceNotFoundError if no response is found", async () => {
prismaMock.response.findUnique.mockResolvedValue(null);
const response = await getResponse(mockResponse.id);
expect(response).toBeNull();
await expect(getResponse(mockResponse.id)).rejects.toThrow(ResourceNotFoundError);
});
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {

View File

@@ -20,7 +20,6 @@ import { personCache } from "../person/cache";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
@@ -32,7 +31,6 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
createdBy: true,
status: true,
welcomeCard: true,
questions: true,
@@ -412,7 +410,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
revalidateSurveyByAttributeClassId([...newFilters, ...removedFilters]);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
@@ -492,30 +489,17 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
const actionClasses = await getActionClasses(environmentId);
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
}
const createdBy = surveyBody.createdBy;
delete surveyBody.createdBy;
const data: Omit<Prisma.SurveyCreateInput, "environment"> = {
// TODO: Create with triggers & attributeFilters
delete surveyBody.triggers;
delete surveyBody.attributeFilters;
const data: Omit<TSurveyInput, "triggers" | "attributeFilters"> = {
...surveyBody,
// TODO: Create with triggers & attributeFilters
triggers: undefined,
attributeFilters: undefined,
};
if (surveyBody.type === "web" && data.thankYouCard) {
data.thankYouCard.buttonLabel = "";
data.thankYouCard.buttonLink = "";
}
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -533,8 +517,6 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
};
await subscribeTeamMembersToSurveyResponses(environmentId, survey.id);
surveyCache.revalidate({
id: survey.id,
environmentId: survey.environmentId,
@@ -543,7 +525,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
return transformedSurvey;
};
export const duplicateSurvey = async (environmentId: string, surveyId: string, userId: string) => {
export const duplicateSurvey = async (environmentId: string, surveyId: string) => {
validateInputs([environmentId, ZId], [surveyId, ZId]);
const existingSurvey = await getSurvey(surveyId);
@@ -564,7 +546,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
createdBy: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
@@ -582,11 +563,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
id: environmentId,
},
},
creator: {
connect: {
id: userId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
: Prisma.JsonNull,

View File

@@ -12,8 +12,6 @@ import {
TSurveyQuestionType,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
import { TTeam } from "@formbricks/types/teams";
import { TUser } from "@formbricks/types/user";
import { selectSurvey } from "../service";
@@ -59,38 +57,6 @@ export const mockDisplay = {
status: null,
};
// id: true,
// name: true,
// email: true,
// emailVerified: true,
// imageUrl: true,
// createdAt: true,
// updatedAt: true,
// onboardingCompleted: true,
// twoFactorEnabled: true,
// identityProvider: true,
// objective: true,
// notificationSettings: true,
export const mockUser: TUser = {
id: mockId,
name: "mock User",
email: "test@unit.com",
emailVerified: currentDate,
imageUrl: "https://www.google.com",
createdAt: currentDate,
updatedAt: currentDate,
onboardingCompleted: true,
twoFactorEnabled: false,
identityProvider: "google",
objective: "improve_user_retention",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedTeamIds: [],
},
};
export const mockPerson: TPerson = {
id: mockId,
userId: mockId,
@@ -161,30 +127,6 @@ const baseSurveyProperties = {
...commonMockProperties,
};
export const mockTeamOutput: TTeam = {
id: mockId,
name: "mock Team",
createdAt: currentDate,
updatedAt: currentDate,
billing: {
stripeCustomerId: null,
features: {
inAppSurvey: {
status: "inactive",
unlimited: false,
},
linkSurvey: {
status: "inactive",
unlimited: false,
},
userTargeting: {
status: "inactive",
unlimited: false,
},
},
},
};
export const mockSurveyOutput: SurveyMock = {
type: "web",
status: "inProgress",
@@ -194,7 +136,6 @@ export const mockSurveyOutput: SurveyMock = {
singleUse: null,
styling: null,
displayPercentage: null,
createdBy: null,
pin: null,
resultShareKey: null,
...baseSurveyProperties,
@@ -218,7 +159,6 @@ export const updateSurveyInput: TSurvey = {
styling: null,
singleUse: null,
displayPercentage: null,
createdBy: null,
pin: null,
resultShareKey: null,
...commonMockProperties,

View File

@@ -24,11 +24,9 @@ import {
mockProduct,
mockSurveyOutput,
mockSurveyWithAttributesOutput,
mockTeamOutput,
mockTransformedSurveyOutput,
mockTransformedSurveyWithAttributesIdOutput,
mockTransformedSurveyWithAttributesOutput,
mockUser,
updateSurveyInput,
} from "./survey.mock";
@@ -237,27 +235,6 @@ describe("Tests for createSurvey", () => {
describe("Happy Path", () => {
it("Creates a survey successfully", async () => {
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.team.findFirst.mockResolvedValueOnce(mockTeamOutput);
prismaMock.user.findMany.mockResolvedValueOnce([
{
...mockUser,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
role: "engineer",
},
]);
prismaMock.user.update.mockResolvedValueOnce({
...mockUser,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
role: "engineer",
});
const createdSurvey = await createSurvey(mockId, createSurveyInput);
expect(createdSurvey).toEqual(mockTransformedSurveyWithAttributesIdOutput);
});
@@ -283,7 +260,7 @@ describe("Tests for duplicateSurvey", () => {
it("Duplicates a survey successfully", async () => {
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
const createdSurvey = await duplicateSurvey(mockId, mockId, mockId);
const createdSurvey = await duplicateSurvey(mockId, mockId);
expect(createdSurvey).toEqual(mockSurveyWithAttributesOutput);
});
});
@@ -293,13 +270,13 @@ describe("Tests for duplicateSurvey", () => {
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
prismaMock.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
});
it("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prismaMock.survey.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(Error);
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(Error);
});
});
});

View File

@@ -15,12 +15,10 @@ import {
ZTeam,
ZTeamCreateInput,
} from "@formbricks/types/teams";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { environmentCache } from "../environment/cache";
import { getProducts } from "../product/service";
import { getUsersWithTeam, updateUser } from "../user/service";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { teamCache } from "./cache";
@@ -398,38 +396,3 @@ export const getTeamBillingInfo = async (teamId: string): Promise<TTeamBilling |
tags: [teamCache.tag.byId(teamId)],
}
)();
export const subscribeTeamMembersToSurveyResponses = async (
environmentId: string,
surveyId: string
): Promise<void> => {
try {
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new ResourceNotFoundError("Team", environmentId);
}
const users = await getUsersWithTeam(team.id);
await Promise.all(
users.map((user) => {
if (!user.notificationSettings?.unsubscribedTeamIds?.includes(team?.id as string)) {
const defaultSettings = { alert: {}, weeklySummary: {} };
const updatedNotificationSettings: TUserNotificationSettings = {
...defaultSettings,
...user.notificationSettings,
};
updatedNotificationSettings.alert[surveyId] = true;
return updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
}
return Promise.resolve();
})
);
} catch (error) {
throw error;
}
};

View File

@@ -3,6 +3,6 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"downlevelIteration": true,
},
"downlevelIteration": true
}
}

View File

@@ -30,7 +30,6 @@ const responseSelection = {
twoFactorEnabled: true,
identityProvider: true,
objective: true,
notificationSettings: true,
};
// function to retrive basic information about a user's user
@@ -230,38 +229,3 @@ export const deleteUser = async (id: string): Promise<TUser> => {
throw error;
}
};
export const getUsersWithTeam = async (teamId: string): Promise<TUser[]> => {
validateInputs([teamId, ZId]);
const users = await prisma.user.findMany({
where: {
memberships: {
some: {
teamId,
},
},
},
select: responseSelection,
});
return users;
};
export const userIdRelatedToApiKey = async (apiKey: string) => {
const userId = await prisma.apiKey.findUnique({
where: { id: apiKey },
select: {
environment: {
select: {
people: {
select: {
userId: true,
},
},
},
},
},
});
return userId;
};

View File

@@ -1,40 +0,0 @@
import cuid2 from "@paralleldrive/cuid2";
import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "../../lib/crypto";
import { env } from "../../lib/env.mjs";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = cuid2.createId();
if (!isEncrypted) {
return cuid;
}
const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY);
return encryptedCuid;
};
// validate the survey single use id
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
try {
let decryptedCuid: string | null = null;
if (surveySingleUseId.length === 64) {
if (!env.FORMBRICKS_ENCRYPTION_KEY) {
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
decryptedCuid = decryptAES128(env.FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
} else {
decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY);
}
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;
}
} catch (error) {
return undefined;
}
};

View File

@@ -8,7 +8,7 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"prettier": "^3.2.5",
"prettier": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/surveys",
"license": "MIT",
"version": "1.5.1",
"version": "1.5.0",
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -7,8 +7,8 @@
"clean": "rimraf node_modules dist turbo"
},
"devDependencies": {
"@types/node": "20.11.16",
"@types/react": "18.2.52",
"@types/node": "20.11.6",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"typescript": "^5.3.3"
}

View File

@@ -416,7 +416,6 @@ export const ZSurvey = z.object({
name: z.string(),
type: ZSurveyType,
environmentId: z.string(),
createdBy: z.string().nullable(),
status: ZSurveyStatus,
attributeFilters: z.array(ZSurveyAttributeFilter),
displayOption: ZSurveyDisplayOption,
@@ -444,7 +443,6 @@ export const ZSurvey = z.object({
export const ZSurveyInput = z.object({
name: z.string(),
type: ZSurveyType.optional(),
createdBy: z.string().cuid().optional(),
status: ZSurveyStatus.optional(),
displayOption: ZSurveyDisplayOption.optional(),
autoClose: z.number().optional(),
@@ -464,13 +462,11 @@ export const ZSurveyInput = z.object({
});
export type TSurvey = z.infer<typeof ZSurvey>;
export type TSurveyDates = {
createdAt: TSurvey["createdAt"];
updatedAt: TSurvey["updatedAt"];
closeOnDate: TSurvey["closeOnDate"];
};
export type TSurveyInput = z.infer<typeof ZSurveyInput>;
export const ZSurveyTSurveyQuestionType = z.union([

View File

@@ -13,14 +13,6 @@ export const ZUserObjective = z.enum([
export type TUserObjective = z.infer<typeof ZUserObjective>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
unsubscribedTeamIds: z.array(z.string()).optional(),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
export const ZUser = z.object({
id: z.string(),
name: z.string().nullable(),
@@ -33,7 +25,6 @@ export const ZUser = z.object({
updatedAt: z.date(),
onboardingCompleted: z.boolean(),
objective: ZUserObjective.nullable(),
notificationSettings: ZUserNotificationSettings,
});
export type TUser = z.infer<typeof ZUser>;
@@ -46,7 +37,6 @@ export const ZUserUpdateInput = z.object({
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
imageUrl: z.string().url().nullish(),
notificationSettings: ZUserNotificationSettings.optional(),
});
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
@@ -63,3 +53,10 @@ export const ZUserCreateInput = z.object({
});
export type TUserCreateInput = z.infer<typeof ZUserCreateInput>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;

View File

@@ -63,7 +63,7 @@ export const Button: React.ForwardRefExoticComponent<
// different styles depending on size
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-md",
size === "base" && "px-6 py-3 text-sm font-medium rounded-md",
size === "lg" && "px-8 py-4 text-base font-medium rounded-md",
size === "lg" && "px-4 py-2 text-base font-medium rounded-md",
size === "icon" &&
"w-10 h-10 justify-center group p-2 border rounded-lg border-transparent text-neutral-400 hover:border-slate-200 transition",
// turn button into a floating action button (fab)

View File

@@ -1,13 +1,11 @@
"use client";
import { CheckIcon, PauseIcon, PencilIcon } from "lucide-react";
import { TSurvey } from "@formbricks/types/surveys";
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Tooltip";
interface SurveyStatusIndicatorProps {
status: TSurvey["status"];
status: string;
tooltip?: boolean;
}
@@ -33,9 +31,9 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
)}
{status === "draft" && (
<div className=" rounded-full bg-slate-200 p-1">
<CheckIcon className="h-3 w-3 text-slate-600" />
{status === "archived" && (
<div className=" rounded-full bg-slate-300 p-1">
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
</div>
)}
</TooltipTrigger>
@@ -63,6 +61,13 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
</div>
) : status === "archived" ? (
<div className="flex items-center space-x-2">
<span>Survey archived.</span>
<div className=" rounded-full bg-slate-300 p-1">
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
</div>
</div>
) : null}
</div>
</TooltipContent>
@@ -79,18 +84,18 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
</span>
)}
{status === "paused" && (
<div className="rounded-full bg-slate-300 p-1">
<div className=" rounded-full bg-slate-300 p-1">
<PauseIcon className="h-3 w-3 text-slate-600" />
</div>
)}
{status === "completed" && (
<div className="rounded-full bg-slate-200 p-1">
<div className=" rounded-full bg-slate-200 p-1">
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
)}
{status === "draft" && (
<div className="rounded-full bg-slate-300 p-1">
<PencilIcon className="h-3 w-3 text-slate-600" />
{status === "archived" && (
<div className=" rounded-full bg-slate-300 p-1">
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
</div>
)}
</span>

View File

@@ -1,211 +0,0 @@
"use server";
import { Prisma } from "@prisma/client";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { prisma } from "../../database/src";
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 duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
return duplicatedSurvey;
}
export async function copyToOtherEnvironmentAction(
environmentId: string,
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,
environmentId,
},
include: {
triggers: {
include: {
actionClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
},
});
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
name: trigger.actionClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.actionClass.description,
type: trigger.actionClass.type,
noCodeConfig: trigger.actionClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
: undefined,
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
}
}
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: targetEnvironmentId,
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
}
}
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
createdBy: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
creator: {
connect: {
id: session.user.id,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? Prisma.JsonNull,
singleUse: existingSurvey.singleUse ?? Prisma.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? Prisma.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? Prisma.JsonNull,
styling: existingSurvey.styling ?? Prisma.JsonNull,
},
});
surveyCache.revalidate({
id: newSurvey.id,
environmentId: targetEnvironmentId,
});
return newSurvey;
}
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");
const survey = await getSurvey(surveyId);
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
};
export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
return generateSurveySingleUseId(isEncrypted);
}

View File

@@ -1,158 +0,0 @@
"use client";
import { Code, Link2Icon } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys";
import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
import { generateSingleUseIdAction } from "../actions";
import SurveyDropDownMenu from "./SurveyDropdownMenu";
interface SurveyCardProps {
survey: TSurvey;
environment: TEnvironment;
otherEnvironment: TEnvironment;
isViewer: boolean;
WEBAPP_URL: string;
orientation: string;
}
export default function SurveyCard({
survey,
environment,
otherEnvironment,
isViewer,
WEBAPP_URL,
orientation,
}: SurveyCardProps) {
const isSurveyCreationDeletionDisabled = isViewer;
const surveyStatusLabel = useMemo(() => {
if (survey.status === "inProgress") return "Active";
else if (survey.status === "completed") return "Completed";
else if (survey.status === "draft") return "Draft";
else if (survey.status === "paused") return "Paused";
}, [survey]);
const [singleUseId, setSingleUseId] = useState<string | undefined>();
useEffect(() => {
if (survey.singleUse?.enabled) {
generateSingleUseIdAction(survey.id, survey.singleUse?.isEncrypted ? true : false).then(setSingleUseId);
} else {
setSingleUseId(undefined);
}
}, [survey]);
const linkHref = useMemo(() => {
return survey.status === "draft"
? `/environments/${environment.id}/surveys/${survey.id}/edit`
: `/environments/${environment.id}/surveys/${survey.id}/summary`;
}, [survey.status, survey.id, environment.id]);
const SurveyTypeIndicator = ({ type }: { type: string }) => (
<div className="flex items-center space-x-2 text-sm text-slate-600">
{type === "web" ? (
<>
<Code className="h-4 w-4" />
<span> In-app</span>
</>
) : (
<>
<Link2Icon className="h-4 w-4" />
<span> Link</span>
</>
)}
</div>
);
const renderGridContent = () => {
return (
<Link
href={linkHref}
key={survey.id}
className="relative col-span-2 flex h-44 flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out hover:scale-105 ">
<div className="flex justify-between">
<SurveyTypeIndicator type={survey.type} />
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
environmentId={environment.id}
environment={environment}
otherEnvironment={otherEnvironment!}
webAppUrl={WEBAPP_URL}
singleUseId={singleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
/>
</div>
<div>
<div className="text-sm font-medium text-slate-900">{survey.name}</div>
<div
className={cn(
"mt-3 flex w-fit items-center gap-2 rounded-full py-1 pl-1 pr-2 text-xs text-slate-800",
surveyStatusLabel === "Active" && "bg-emerald-50",
surveyStatusLabel === "Completed" && "bg-slate-200",
surveyStatusLabel === "Draft" && "bg-slate-100",
surveyStatusLabel === "Paused" && "bg-slate-100"
)}>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}
</div>
</div>
</Link>
);
};
const renderListContent = () => {
return (
<Link
href={linkHref}
key={survey.id}
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
shadow-sm transition-all ease-in-out hover:scale-[101%]">
<div className="col-span-2 flex items-center justify-self-start overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-slate-900">
{survey.name}
</div>
<div
className={cn(
"flex w-fit items-center gap-2 rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
surveyStatusLabel === "Active" && "bg-emerald-50",
surveyStatusLabel === "Completed" && "bg-slate-200",
surveyStatusLabel === "Draft" && "bg-slate-100",
surveyStatusLabel === "Paused" && "bg-slate-100"
)}>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
</div>
<div className="flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-4 grid w-full grid-cols-5 place-items-center">
<div className="col-span-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.createdAt.toString())}
</div>
<div className="col-span-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString())}
</div>
<div className="place-self-end">
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
environmentId={environment.id}
environment={environment}
otherEnvironment={otherEnvironment!}
webAppUrl={WEBAPP_URL}
singleUseId={singleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
/>
</div>
</div>
</Link>
);
};
if (orientation === "grid") return renderGridContent();
else return renderListContent();
}

View File

@@ -1,309 +0,0 @@
"use client";
import { ChevronDownIcon, Equal, Grid2X2, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "../../Button";
import { Checkbox } from "../../Checkbox";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
import { TooltipRenderer } from "../../Tooltip";
interface SurveyFilterProps {
surveys: TSurvey[];
setFilteredSurveys: (surveys: TSurvey[]) => void;
orientation: string;
setOrientation: (orientation: string) => void;
userId: string;
}
interface TFilterOption {
label: string;
value: string;
}
interface TSortOption {
label: string;
sortFunction: (a: TSurvey, b: TSurvey) => number;
}
interface FilterDropdownProps {
title: string;
id: string;
options: TFilterOption[];
selectedOptions: string[];
setSelectedOptions: (options: string[]) => void;
isOpen: boolean;
}
const statusOptions = [
{ label: "In Progress", value: "inProgress" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Draft", value: "draft" },
];
const typeOptions = [
{ label: "Link", value: "link" },
{ label: "In-app", value: "web" },
];
const sortOptions = [
{
label: "Last Modified",
sortFunction: (a: TSurvey, b: TSurvey) => {
const dateA = new Date(a.updatedAt);
const dateB = new Date(b.updatedAt);
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
return dateB.getTime() - dateA.getTime();
}
return 0;
},
},
{
label: "Created On",
sortFunction: (a: TSurvey, b: TSurvey) => {
const dateA = new Date(a.createdAt);
const dateB = new Date(b.createdAt);
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
return dateB.getTime() - dateA.getTime();
}
return 0;
},
},
{
label: "Alphabetical",
sortFunction: (a: TSurvey, b: TSurvey) => a.name.localeCompare(b.name),
},
// Add other sorting options as needed
];
const getToolTipContent = (orientation: string) => {
return <div>{orientation} View</div>;
};
export default function SurveyFilters({
surveys,
setFilteredSurveys,
orientation,
setOrientation,
userId,
}: SurveyFilterProps) {
const [createdByFilter, setCreatedByFilter] = useState<string[]>([]);
const [statusFilters, setStatusFilters] = useState<string[]>([]);
const [typeFilters, setTypeFilters] = useState<string[]>([]);
const [sortBy, setSortBy] = useState(sortOptions[0]);
const [searchTerm, setSearchTerm] = useState("");
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
const toggleDropdown = (id: string) => {
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
};
const creatorOptions = [
{ label: "You", value: userId },
{ label: "Others", value: "other" },
];
useEffect(() => {
let filtered = [...surveys];
// Filter by search term
if (searchTerm) {
filtered = filtered.filter((survey) => survey.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
if (createdByFilter.length > 0) {
filtered = filtered.filter((survey) => {
if (survey.createdBy) {
if (createdByFilter.length === 2) return true;
if (createdByFilter.includes("other")) return survey.createdBy !== userId;
else {
return survey.createdBy === userId;
}
}
});
}
if (statusFilters.length > 0) {
filtered = filtered.filter((survey) => statusFilters.includes(survey.status));
}
if (typeFilters.length > 0) {
filtered = filtered.filter((survey) => typeFilters.includes(survey.type));
}
if (sortBy && sortBy.sortFunction) {
filtered.sort(sortBy.sortFunction);
}
setFilteredSurveys(filtered);
}, [createdByFilter, statusFilters, typeFilters, sortBy, searchTerm, surveys]);
const handleFilterChange = (
value: string,
selectedOptions: string[],
setSelectedOptions: (options: string[]) => void
) => {
if (selectedOptions.includes(value)) {
setSelectedOptions(selectedOptions.filter((option) => option !== value));
} else {
setSelectedOptions([...selectedOptions, value]);
}
};
const renderSortOption = (option: TSortOption) => (
<DropdownMenuItem
key={option.label}
className="m-0 p-0"
onClick={() => {
setSortBy(option);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<span
className={`h-4 w-4 rounded-full border ${sortBy === option ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
);
const FilterDropdown = ({
title,
id,
options,
selectedOptions,
setSelectedOptions,
isOpen,
}: FilterDropdownProps) => {
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
return (
<DropdownMenu open={isOpen} onOpenChange={() => toggleDropdown(id)}>
<DropdownMenuTrigger asChild className={triggerClasses}>
<div className="flex w-full items-center justify-between">
<span className="text-sm">{title}</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900">
{options.map((option) => (
<DropdownMenuItem
key={option.value}
className="m-0 p-0"
onClick={(e) => {
e.preventDefault();
handleFilterChange(option.value, selectedOptions, setSelectedOptions);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<Checkbox
checked={selectedOptions.includes(option.value)}
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
/>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<div className="flex justify-between">
<div className="flex space-x-2">
<div className="flex h-8 items-center rounded-lg border border-slate-300 bg-white px-4">
<Search className="h-4 w-4" />
<input
type="text"
className="border-none bg-transparent placeholder:text-sm"
placeholder="Search by survey name"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div>
<FilterDropdown
title="Created By"
id="creatorDropdown"
options={creatorOptions}
selectedOptions={createdByFilter}
setSelectedOptions={setCreatedByFilter}
isOpen={dropdownOpenStates.get("creatorDropdown")}
/>
</div>
<div>
<FilterDropdown
title="Status"
id="statusDropdown"
options={statusOptions}
selectedOptions={statusFilters}
setSelectedOptions={setStatusFilters}
isOpen={dropdownOpenStates.get("statusDropdown")}
/>
</div>
<div>
<FilterDropdown
title="Type"
id="typeDropdown"
options={typeOptions}
selectedOptions={typeFilters}
setSelectedOptions={setTypeFilters}
isOpen={dropdownOpenStates.get("typeDropdown")}
/>
</div>
{(createdByFilter.length > 0 || statusFilters.length > 0 || typeFilters.length > 0) && (
<Button
variant="darkCTA"
size="sm"
onClick={() => {
setCreatedByFilter([]);
setStatusFilters([]);
setTypeFilters([]);
}}
className="h-8"
EndIcon={X}
endIconClassName="h-4 w-4">
Clear Filters
</Button>
)}
</div>
<div className="flex space-x-2">
<TooltipRenderer
shouldRender={true}
tooltipContent={getToolTipContent("List")}
className="bg-slate-900 text-white">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "list" ? "bg-slate-900 text-white" : "bg-white"}`}
onClick={() => setOrientation("list")}>
<Equal className="h-5 w-5" />
</div>
</TooltipRenderer>
<TooltipRenderer
shouldRender={true}
tooltipContent={getToolTipContent("Grid")}
className="bg-slate-900 text-white">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "grid" ? "bg-slate-900 text-white" : "bg-white"}`}
onClick={() => setOrientation("grid")}>
<Grid2X2 className="h-5 w-5" />
</div>
</TooltipRenderer>
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="surveyFilterDropdown h-full cursor-pointer border border-slate-700 outline-none hover:bg-slate-900">
<div className="min-w-auto h-8 rounded-md border sm:flex sm:px-2">
<div className="hidden w-full items-center justify-between hover:text-white sm:flex">
<span className="text-sm ">Sort by: {sortBy.label}</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900 ">
{sortOptions.map(renderSortOption)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "../v2/Button";
import SurveyCard from "./components/SurveyCard";
import SurveyFilters from "./components/SurveyFilters";
interface SurveysListProps {
environment: TEnvironment;
surveys: TSurvey[];
otherEnvironment: TEnvironment;
isViewer: boolean;
WEBAPP_URL: string;
userId: string;
}
export default function SurveysList({
environment,
surveys,
otherEnvironment,
isViewer,
WEBAPP_URL,
userId,
}: SurveysListProps) {
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
const [orientation, setOrientation] = useState("grid");
return (
<div className="space-y-4">
<div className="flex justify-between">
<h1 className="my-2 text-3xl font-bold text-slate-800">Surveys</h1>
<Button
href={`/environments/${environment.id}/surveys/templates`}
variant="darkCTA"
EndIcon={PlusIcon}>
New survey
</Button>
</div>
<SurveyFilters
surveys={surveys}
setFilteredSurveys={setFilteredSurveys}
orientation={orientation}
setOrientation={setOrientation}
userId={userId}
/>
{filteredSurveys.length > 0 ? (
<div>
{orientation === "list" && (
<div className="flex-col space-y-3">
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 text-sm text-slate-800">
<div className="col-span-4 place-self-start">Name</div>
<div className="col-span-4 grid w-full grid-cols-5 place-items-center">
<div className="col-span-2">Created at</div>
<div className="col-span-2">Updated at</div>
</div>
</div>
{filteredSurveys.map((survey) => {
return (
<SurveyCard
survey={survey}
environment={environment}
otherEnvironment={otherEnvironment}
isViewer={isViewer}
WEBAPP_URL={WEBAPP_URL}
orientation={orientation}
/>
);
})}
</div>
)}
{orientation === "grid" && (
<div className="grid grid-cols-4 place-content-stretch gap-4 lg:grid-cols-6 ">
{filteredSurveys.map((survey) => {
return (
<SurveyCard
survey={survey}
environment={environment}
otherEnvironment={otherEnvironment}
isViewer={isViewer}
WEBAPP_URL={WEBAPP_URL}
orientation={orientation}
/>
);
})}
</div>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center">
<span className="mb-4 h-24 w-24 rounded-full bg-slate-100 p-6 text-5xl">🕵</span>
<div className="text-slate-600">No surveys found</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,6 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { ReactNode } from "react";
import { cn } from "@formbricks/lib/cn";
@@ -32,25 +31,3 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
className?: string;
}
export function TooltipRenderer(props: TooltipRendererProps) {
const { children, shouldRender, tooltipContent, className } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent className={className}>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
}

View File

@@ -19,13 +19,13 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/lib": "workspace:*",
"@heroicons/react": "^2.1.1",
"@lexical/code": "^0.13.1",
"@lexical/link": "^0.13.1",
"@lexical/list": "^0.13.1",
"@lexical/markdown": "^0.13.1",
"@lexical/react": "^0.13.1",
"@lexical/rich-text": "^0.13.1",
"@lexical/table": "^0.13.1",
"@lexical/code": "^0.13.0",
"@lexical/link": "^0.13.0",
"@lexical/list": "^0.13.0",
"@lexical/markdown": "^0.13.0",
"@lexical/react": "^0.13.0",
"@lexical/rich-text": "^0.13.0",
"@lexical/table": "^0.13.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@@ -39,9 +39,9 @@
"boring-avatars": "^1.10.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.1",
"lexical": "^0.13.1",
"lucide-react": "^0.321.0",
"cmdk": "^0.2.0",
"lexical": "^0.13.0",
"lucide-react": "^0.315.0",
"react-colorful": "^5.6.1",
"react-confetti": "^6.1.0",
"react-day-picker": "^8.10.0",

View File

@@ -1,6 +1,6 @@
{
"extends": "@formbricks/tsconfig/react-library.json",
"include": [".", "../types/*.d.ts"],
"include": ["."],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"lib": ["ES2021.String"]

2910
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff